iit-mcp-autotask 0.15.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 (94) hide show
  1. package/.env.example +9 -5
  2. package/README.md +49 -68
  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 +5 -0
  34. package/dist/tools/dispatch.d.ts +2 -1
  35. package/dist/tools/dispatch.js +13 -7
  36. package/dist/tools/meta.js +1 -1
  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 +6 -15
  41. package/dist/tools/server-version.d.ts +1 -1
  42. package/dist/tools/server-version.js +1 -1
  43. package/dist/tools/server.d.ts +4 -4
  44. package/dist/tools/server.js +25 -14
  45. package/dist/tools/surface.d.ts +6 -2
  46. package/dist/tools/surface.js +45 -0
  47. package/dist/tools/write/apply-phases.d.ts +18 -13
  48. package/dist/tools/write/apply-phases.js +43 -75
  49. package/dist/tools/write/apply.js +32 -9
  50. package/dist/tools/write/facts.d.ts +7 -21
  51. package/dist/tools/write/facts.js +7 -30
  52. package/dist/tools/write/outcome.d.ts +3 -8
  53. package/dist/tools/write/outcome.js +1 -19
  54. package/dist/tools/write/payload.d.ts +1 -1
  55. package/dist/tools/write/payload.js +1 -1
  56. package/dist/tools/write/preview.js +26 -53
  57. package/dist/tools/write/snapshot.d.ts +0 -1
  58. package/dist/tools/write/snapshot.js +2 -3
  59. package/dist/tools/write.d.ts +1 -1
  60. package/dist/tools/write.js +1 -1
  61. package/dist/tty.d.ts +13 -0
  62. package/dist/tty.js +44 -0
  63. package/dist/write/canonical.d.ts +6 -0
  64. package/dist/write/canonical.js +4 -0
  65. package/dist/write/evaluate.d.ts +13 -37
  66. package/dist/write/evaluate.js +21 -89
  67. package/dist/write/hard-rules.d.ts +12 -14
  68. package/dist/write/hard-rules.js +2 -4
  69. package/dist/write/runtime.d.ts +9 -6
  70. package/dist/write/runtime.js +22 -14
  71. package/dist/write/store/constants.d.ts +1 -1
  72. package/dist/write/store/constants.js +1 -1
  73. package/dist/write/store/ledger.d.ts +1 -1
  74. package/dist/write/store/ledger.js +1 -14
  75. package/dist/write/store/plan-store.d.ts +3 -6
  76. package/dist/write/store/plan-store.js +3 -31
  77. package/dist/write/store/plans.d.ts +1 -1
  78. package/dist/write/store/plans.js +1 -1
  79. package/dist/write/store/types.d.ts +5 -34
  80. package/dist/write/store.d.ts +1 -1
  81. package/dist/write/store.js +1 -3
  82. package/examples/agentgateway.write-gate.yaml +29 -0
  83. package/package.json +3 -3
  84. package/server.json +4 -6
  85. package/skills/log-time-entry.md +5 -5
  86. package/dist/approvals.d.ts +0 -112
  87. package/dist/approvals.js +0 -440
  88. package/dist/write/grant.d.ts +0 -52
  89. package/dist/write/grant.js +0 -159
  90. package/dist/write/manual.d.ts +0 -28
  91. package/dist/write/manual.js +0 -54
  92. package/dist/write/policy-file.d.ts +0 -136
  93. package/dist/write/policy-file.js +0 -231
  94. 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
@@ -86,7 +86,7 @@ part miss never fails the call (typed `absent`); only a missing root record fail
86
86
  **Every result is one envelope** (`structuredContent`, mirrored as JSON in the first text block):
87
87
 
88
88
  ```
89
- { status: ok|needs_input|ambiguous|needs_approval|denied|error,
89
+ { status: ok|needs_input|ambiguous|denied|error,
90
90
  data, next: proceed|supply_input|choose_candidate|...|retry|stop,
91
91
  reasons: [{ code, field?, detail }], retryable, correlationId, summary }
92
92
  ```
@@ -120,7 +120,7 @@ server metadata: [`server.json`](server.json) · per-workflow mapping:
120
120
 
121
121
  | Variable | Default | Effect |
122
122
  |----------|---------|--------|
123
- | `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`. |
124
124
  | `AUTOTASK_TOOLSETS` | — (core only) | comma list of `service`, `records`, `finance`, or `all`; an unknown name fails boot |
125
125
 
126
126
  Every write and delete is refused on the read path (`autotask_read`) before any request
@@ -143,65 +143,44 @@ An operator deployment needs:
143
143
  | Variable | Meaning |
144
144
  |----------|---------|
145
145
  | `AUTOTASK_PROFILE=operator` (or an operator token) | the profile |
146
- | `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)) |
147
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) |
148
- | `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 |
149
149
  | `AUTOTASK_PRINCIPAL` | stdio: the name plans and the audit log are bound to (default `stdio`) |
150
150
  | `AUTOTASK_KILL_FILE` | optional: while this file exists every preview and apply is denied (default `<state dir>/KILL`) |
151
151
 
152
- What the policy can and cannot do:
153
-
154
- - An operation the policy does not list is `OP_NOT_ALLOWED`. Per operation: `approval`
155
- (`none`/`required`), `maxPerHour`, `maxPerDay`, `maxDistinctTargetsPerDay`, a `fields` allowlist,
156
- `forbidRemovals`, `requireReason`, UTC `windows`, `duplicateCheck` (required for creates) and
157
- `allowUnattended`. Globally: `write.scope.companyIds`, `createdHereWithinDays`, and
158
- `limits.writesPerHour`/`writesPerDay`.
159
- - Moving a time entry or ticket charge to another ticket (#107): list `time_entries.moveToTicket` or
160
- `ticket_charges.moveToTicket` with an `owner` block, e.g. `{"approval": "none", "allowUnattended": true,
161
- "owner": {"mode": "createdHere", "days": 7}}` (`createdHere`: this server created the source for this
162
- principal within 1-14 days; or `{"mode": "resourceIDs", "ids": [..]}` for time entries, whose `resourceID`
163
- must be listed; a charge has no owning resource). Preview `autotask_write {operation, args: [sourceId,
164
- toTicketId]}`, then apply. The server creates the copy on the target ticket, verifies it and deletes the
165
- original (the vendor ignores an update of `ticketID`): the new record gets a new id, create date and
166
- creator, and notifications may fire. A move into another company or contract always needs a signed
167
- approval grant; an unfinished earlier move of the same source blocks another until its ledger entry ages
168
- out (14 days) or a person clears it: in a terminal, as the server's OS user, with `AUTOTASK_STATE_DIR` set,
169
- `autotask-mcp moves list` shows the blocked sources and `autotask-mcp moves resolve <resource>:<id>` (after you
170
- checked both tickets in Autotask; you type the source id, and the copy's id when the ledger names one) closes the
171
- ledger lines as `MOVE_RESOLVED` and audits it (`move_resolved`). It refuses within 10 minutes of the move being sent
172
- (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
173
- before the resolve cannot apply after it. It is not an
174
- 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.
175
- - Hard rules no policy overrides: `authenticate.*` is never served; operations the SDK flags
176
- `requiresApproval`, and non-read sensitive ones (webhooks, `resources.transferOwnership`), always
177
- need approval; destructive and account-wide operations need it, and `allowUnattended` cannot waive
178
- that for an operation the hard rules cover. In approval mode `policy` no approver
179
- exists: an approval-required write answers `needs_approval` with `next: manual_action` (no plan
180
- handle) and is never executed. Approval mode `external` (`approvalMode: "external"` plus an `approvals`
181
- block listing ed25519 approver keys, each scoped to operations) is being added in stages (#106): a gated
182
- preview stores a pending plan (`next: await_approval`, handle, hash, expiry of grant window + 5 minutes) that
183
- executes only with a valid ed25519 grant file `approvals/grants/<handle>.json` signed by a listed approver
184
- (apply verifies it; deny rules still win; one use per plan). A human decides with the CLI, run in their own
185
- terminal as the server's OS user with `AUTOTASK_STATE_DIR` and `AUTOTASK_POLICY_FILE` set as the server has them:
186
- `autotask-mcp keygen <approver-id> --ops <op,op|*>` (creates a passphrase-sealed ed25519 key, mode 0600, default
187
- `~/.config/iit-mcp-autotask/approver.key` or `$AUTOTASK_APPROVER_KEY_FILE`, and prints the entry for
188
- `approvals.approvers`), then `autotask-mcp approvals list|show|approve|reject <handle>`. `show` and `approve`
189
- recompute the plan hash and snapshot hash from the stored plan and refuse on a mismatch or a changed policy; they
190
- print only verified fields, and every agent-supplied string (arguments, the agent's reason) is JSON-encoded behind
191
- `| ` inside a labelled untrusted block with control and bidi characters stripped. `approve` and `reject` need you to
192
- type the first 8 characters of the plan hash and the key passphrase; the passphrase is read from `/dev/tty` only
193
- (never the environment, arguments, a pipe or a file; there is no `--yes`), so the command refuses without a
194
- 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
195
- pty, and on stdio the sealed key file can be attacked offline by anything running as the same user. On HTTP run it
196
- where the state dir lives (for example `docker exec -it` as the server's user). On stdio this is a
197
- guardrail against a mistaken agent, not a boundary against an adversarial agent running as the same OS
198
- user; it is a real boundary only on HTTP with the server, its key material and the policy file isolated
199
- from the agent. Any edit to the policy file (including `approvals`) changes the policy hash and voids
200
- every outstanding plan. `data.manual` carries a fingerprint of the record (ids, ticket,
201
- start time, hours, resource — numbers and instants only, never vendor text) and, when the pinned
202
- SDK provides `operations.uiLink`, a clickable Autotask deep link; a person finishes the job in
203
- Autotask. A policy that sets `approval: "none"` on such an operation changes nothing: boot logs a
204
- 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`.
205
184
  - The company scope is read from the live records the write touches (the target, its parent, the
206
185
  ticket, project, task or contract it references, and any `companyID` it sets), never from names
207
186
  or titles. A reference that cannot be read is refused.
@@ -215,7 +194,7 @@ precomposed form after NFC (for example a lone U+0301) is refused `INPUT_INVALID
215
194
  precomposed character.
216
195
 
217
196
  Outcomes the caller acts on: `ok`/`proceed`; `needs_input`/`supply_input` (`missing` names the
218
- field); `needs_approval`/`await_approval`; `denied`/`stop`; `STATE_DRIFT` or `PLAN_EXPIRED` →
197
+ field); `denied`/`stop`; `STATE_DRIFT` or `PLAN_EXPIRED` →
219
198
  `repreview`; a failed write: `WRITE_FAILED` with `stop` (a deterministic rejection; it replays,
220
199
  fix the input and use a new key) or `RATE_LIMITED` with `retry` (nothing was written: apply the
221
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`,
@@ -473,7 +452,7 @@ No aliases: old names fail as unknown tools.
473
452
  | `quote_get`, `contact_full_profile`, `contract_full_profile`, `project_get_context`, `resource_workload` | same names (toolsets) |
474
453
  | `configurationItem_profile`, `serviceCall_full_profile` | `configuration_item_profile`, `service_call_full_profile` |
475
454
  | `write`, `delete`, `timeEntry_create` | `write {operation, args, idempotency_key?}` then `apply {plan}` (operator profile) |
476
- | 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 |
477
456
  | CLI default stdio, bind on all interfaces | http on `127.0.0.1` by default; local clients pass `--transport stdio` |
478
457
  | results: per-tool shapes, errors as a text JSON payload | one envelope; the old payload is the envelope `data` |
479
458
 
@@ -488,12 +467,11 @@ No aliases: old names fail as unknown tools.
488
467
  `tokens`); a server-held credential on HTTP needs `tokens`.
489
468
  4. Read-only use needs nothing else. Pick toolsets with `AUTOTASK_TOOLSETS` if the agent used the
490
469
  service, records or finance tools.
491
- 5. Writes (the operator profile): write a policy file (start from
492
- `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
493
471
  check, Service tickets need `startDateTime` and `endDateTime`, and `billingCodeID` must be an active
494
472
  general allocation code), create the state dir's parent, and set
495
- `AUTOTASK_PROFILE=operator`, `AUTOTASK_POLICY_FILE`, `AUTOTASK_STATE_DIR` and a run scope
496
- (`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
497
475
  dir (0700), its key and its lock. There is nothing to migrate: 0.5.x kept no write state.
498
476
  6. Update agent prompts and playbooks to the new tool names (table above) and to the envelope's
499
477
  `status`/`next`.
@@ -513,7 +491,7 @@ preview/apply path; a plan previewed under 0.6.0 cannot be applied after a rollb
513
491
  | `GET /health` | process alive — pre-auth, **no vendor call** (container healthcheck) | none |
514
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 |
515
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 |
516
- | `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 |
517
495
 
518
496
  ## Configuration
519
497
 
@@ -524,9 +502,12 @@ non-zero on an invalid config (fail-fast) instead of failing at the first tool c
524
502
  |----------|----------|---------|---------|
525
503
  | `MCP_TRANSPORT` | no | `http` | `stdio` or `http`; the CLI `--transport` flag overrides |
526
504
  | `AUTOTASK_PROFILE` | no | `readonly` | `readonly` or `operator` (see [Profiles and writes](#profiles-and-writes)) |
527
- | `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 |
528
509
  | `AUTOTASK_STATE_DIR` | operator | — | the operator state dir (0700) |
529
- | `AUTOTASK_RUN_SCOPE` | operator on stdio | — | `{"companyIds":[...]}` or `{"all":true}` |
510
+ | `AUTOTASK_RUN_SCOPE` | operator on stdio or gateway | — | `{"companyIds":[...]}` or `{"all":true}` |
530
511
  | `AUTOTASK_PRINCIPAL` | no | `stdio` | the stdio operator principal |
531
512
  | `AUTOTASK_KILL_FILE` | no | `<state dir>/KILL` | the kill switch file |
532
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. |
@@ -549,7 +530,7 @@ non-zero on an invalid config (fail-fast) instead of failing at the first tool c
549
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 |
550
531
  | `AUTOTASK_HTTP_ALLOWED_HOSTS` | off loopback | — | `Host` names `/mcp` accepts (DNS-rebinding defence) |
551
532
  | `AUTOTASK_HTTP_ALLOWED_ORIGINS` | no | — | browser `Origin` hostnames `/mcp` accepts; a request without an `Origin` passes |
552
- | `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` |
553
534
  | `AUTOTASK_HTTP_TOKENS_FILE` | with `tokens` | — | the tokens file (see [Gateway (http, container)](#gateway-http-container)) |
554
535
  | `AUTOTASK_HTTP_RATE_PER_MIN` | no | `60` | requests a minute per principal in `tokens` mode |
555
536
  | `PORT` | no | `8080` | http listener port (`--port` overrides it) |
@@ -855,7 +836,7 @@ malformed).
855
836
  ## Gateway assumptions
856
837
 
857
838
  With `AUTOTASK_HTTP_AUTH=gateway` the gateway is the control plane. It owns: client authentication,
858
- 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
859
840
  limiting (`/metadata` states the delegation). The server still validates `Host`/`Origin` and caps
860
841
  bodies at 1 MiB. With `AUTOTASK_HTTP_AUTH=tokens` this process authenticates callers and
861
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
  *
package/dist/auth.js CHANGED
@@ -32,6 +32,10 @@ export const AUTOTASK_SECRET_HEADER = 'x-autotask-secret';
32
32
  export 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 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 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 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 const CREDENTIAL_HEADERS = [
37
41
  AUTOTASK_INTEGRATION_CODE_HEADER,
@@ -77,6 +81,21 @@ export function readRequestScope(headers) {
77
81
  ...(base !== undefined ? { baseUrl: base } : {}),
78
82
  };
79
83
  }
84
+ const GATEWAY_ID = /^[A-Za-z0-9._@:-]{1,200}$/;
85
+ /**
86
+ * Read the identity the gateway stamped on the request. Callers must read it ONLY in gateway
87
+ * mode: anywhere else these headers are caller-controlled and mean nothing. Duplicated or
88
+ * malformed values are absent.
89
+ */
90
+ export function readGatewayIdentity(headers) {
91
+ const valid = (name) => {
92
+ const v = usable(readSingleHeader(headers, name));
93
+ return v !== undefined && GATEWAY_ID.test(v) ? v : undefined;
94
+ };
95
+ const clientId = valid(GATEWAY_CLIENT_ID_HEADER);
96
+ const principal = valid(GATEWAY_PRINCIPAL_HEADER);
97
+ return { ...(clientId !== undefined ? { clientId } : {}), ...(principal !== undefined ? { principal } : {}) };
98
+ }
80
99
  /**
81
100
  * How many of the three identity members this scope presents.
82
101
  *