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.
- package/.env.example +9 -5
- package/README.md +58 -76
- package/capabilities.json +2 -4
- package/dist/access/gate.d.ts +29 -0
- package/dist/access/gate.js +75 -0
- package/dist/access/write-env.d.ts +27 -0
- package/dist/access/write-env.js +161 -0
- package/dist/auth.d.ts +13 -0
- package/dist/auth.js +19 -0
- package/dist/config/constants.d.ts +2 -2
- package/dist/config/constants.js +7 -2
- package/dist/config/load.js +1 -1
- package/dist/config/parse.d.ts +1 -1
- package/dist/config/parse.js +1 -1
- package/dist/config/schema.d.ts +8 -4
- package/dist/config/schema.js +7 -23
- package/dist/config.d.ts +0 -1
- package/dist/config.js +0 -1
- package/dist/doctor.d.ts +29 -1
- package/dist/doctor.js +6 -29
- package/dist/http.d.ts +1 -1
- package/dist/http.js +15 -4
- package/dist/main.js +10 -11
- package/dist/mocks/harness.js +1 -1
- package/dist/moves.d.ts +1 -1
- package/dist/moves.js +3 -3
- package/dist/policy.d.ts +1 -1
- package/dist/policy.js +2 -2
- package/dist/tools/capabilities-manifest.js +3 -3
- package/dist/tools/catalog-data.d.ts +1 -1
- package/dist/tools/catalog-data.js +1 -1
- package/dist/tools/context/derive.js +1 -1
- package/dist/tools/context/types.d.ts +6 -2
- package/dist/tools/dispatch.d.ts +3 -2
- package/dist/tools/dispatch.js +31 -13
- package/dist/tools/meta.js +9 -33
- package/dist/tools/reasons.d.ts +3 -3
- package/dist/tools/reasons.js +3 -12
- package/dist/tools/register.js +10 -4
- package/dist/tools/schemas.d.ts +20 -27
- package/dist/tools/schemas.js +32 -19
- package/dist/tools/server-version.d.ts +1 -1
- package/dist/tools/server-version.js +1 -1
- package/dist/tools/server.d.ts +4 -4
- package/dist/tools/server.js +25 -14
- package/dist/tools/surface.d.ts +6 -2
- package/dist/tools/surface.js +45 -0
- package/dist/tools/write/apply-phases.d.ts +18 -13
- package/dist/tools/write/apply-phases.js +43 -75
- package/dist/tools/write/apply.js +32 -9
- package/dist/tools/write/facts.d.ts +7 -21
- package/dist/tools/write/facts.js +7 -30
- package/dist/tools/write/outcome.d.ts +3 -8
- package/dist/tools/write/outcome.js +1 -19
- package/dist/tools/write/payload.d.ts +1 -1
- package/dist/tools/write/payload.js +1 -1
- package/dist/tools/write/preview.js +26 -53
- package/dist/tools/write/snapshot.d.ts +0 -1
- package/dist/tools/write/snapshot.js +2 -3
- package/dist/tools/write.d.ts +1 -1
- package/dist/tools/write.js +1 -1
- package/dist/tty.d.ts +13 -0
- package/dist/tty.js +44 -0
- package/dist/write/canonical.d.ts +6 -0
- package/dist/write/canonical.js +4 -0
- package/dist/write/evaluate.d.ts +13 -37
- package/dist/write/evaluate.js +21 -89
- package/dist/write/hard-rules.d.ts +12 -14
- package/dist/write/hard-rules.js +2 -4
- package/dist/write/runtime.d.ts +9 -6
- package/dist/write/runtime.js +22 -14
- package/dist/write/store/constants.d.ts +1 -1
- package/dist/write/store/constants.js +1 -1
- package/dist/write/store/ledger.d.ts +1 -1
- package/dist/write/store/ledger.js +1 -14
- package/dist/write/store/plan-store.d.ts +3 -6
- package/dist/write/store/plan-store.js +3 -31
- package/dist/write/store/plans.d.ts +1 -1
- package/dist/write/store/plans.js +1 -1
- package/dist/write/store/types.d.ts +5 -34
- package/dist/write/store.d.ts +1 -1
- package/dist/write/store.js +1 -3
- package/examples/agentgateway.write-gate.yaml +29 -0
- package/package.json +3 -3
- package/server.json +4 -6
- package/skills/filter-semantics.md +2 -4
- package/skills/log-time-entry.md +5 -5
- package/dist/approvals.d.ts +0 -112
- package/dist/approvals.js +0 -440
- package/dist/write/grant.d.ts +0 -52
- package/dist/write/grant.js +0 -159
- package/dist/write/manual.d.ts +0 -28
- package/dist/write/manual.js +0 -54
- package/dist/write/policy-file.d.ts +0 -136
- package/dist/write/policy-file.js +0 -231
- 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
|
|
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:
|
|
56
|
-
#
|
|
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} (
|
|
60
|
-
#
|
|
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.
|
|
48
|
-
|
|
49
|
-
send a mixed-type array
|
|
50
|
-
|
|
51
|
-
`
|
|
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|
|
|
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
|
|
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
|
-
| `
|
|
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` |
|
|
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
|
|
152
|
-
|
|
153
|
-
-
|
|
154
|
-
|
|
155
|
-
`
|
|
156
|
-
`
|
|
157
|
-
|
|
158
|
-
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
before the resolve cannot apply after it. It is not an
|
|
173
|
-
|
|
174
|
-
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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); `
|
|
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` + `
|
|
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
|
|
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`, `
|
|
495
|
-
(`AUTOTASK_RUN_SCOPE` on stdio, `runScope` per token
|
|
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,
|
|
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
|
-
| `
|
|
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
|
|
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
|
|
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":
|
|
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, '<').replace(/>/g, '>');
|
|
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
|
*
|