iit-mcp-autotask 0.10.0 → 0.11.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 (57) hide show
  1. package/README.md +62 -18
  2. package/capabilities.json +5 -5
  3. package/dist/approvals.d.ts +112 -0
  4. package/dist/approvals.js +440 -0
  5. package/dist/config.d.ts +1 -1
  6. package/dist/config.js +16 -1
  7. package/dist/doctor.js +11 -0
  8. package/dist/main.js +10 -0
  9. package/dist/mocks/fixture-ops.d.ts +8 -0
  10. package/dist/mocks/fixture-ops.js +33 -1
  11. package/dist/tools/catalog-data.d.ts +1 -1
  12. package/dist/tools/catalog-data.js +6 -6
  13. package/dist/tools/composites.js +8 -5
  14. package/dist/tools/dispatch.js +20 -1
  15. package/dist/tools/field-pointer.d.ts +10 -0
  16. package/dist/tools/field-pointer.js +13 -0
  17. package/dist/tools/lists.js +11 -6
  18. package/dist/tools/meta.js +77 -3
  19. package/dist/tools/output-file.d.ts +5 -1
  20. package/dist/tools/output-file.js +29 -4
  21. package/dist/tools/reasons.d.ts +2 -2
  22. package/dist/tools/reasons.js +10 -0
  23. package/dist/tools/resolves.js +44 -4
  24. package/dist/tools/schemas.d.ts +23 -0
  25. package/dist/tools/schemas.js +44 -7
  26. package/dist/tools/server.d.ts +3 -3
  27. package/dist/tools/server.js +9 -3
  28. package/dist/tools/tsv.d.ts +3 -1
  29. package/dist/tools/tsv.js +14 -2
  30. package/dist/tools/write.d.ts +6 -1
  31. package/dist/tools/write.js +404 -31
  32. package/dist/write/canonical.js +1 -1
  33. package/dist/write/evaluate.d.ts +9 -0
  34. package/dist/write/evaluate.js +8 -1
  35. package/dist/write/grant.d.ts +52 -0
  36. package/dist/write/grant.js +159 -0
  37. package/dist/write/hard-rules.d.ts +7 -1
  38. package/dist/write/hard-rules.js +9 -1
  39. package/dist/write/manual.d.ts +28 -0
  40. package/dist/write/manual.js +54 -0
  41. package/dist/write/persisted.d.ts +26 -0
  42. package/dist/write/persisted.js +63 -0
  43. package/dist/write/policy-file.d.ts +58 -0
  44. package/dist/write/policy-file.js +124 -5
  45. package/dist/write/runtime.js +2 -0
  46. package/dist/write/store.d.ts +53 -2
  47. package/dist/write/store.js +99 -19
  48. package/package.json +2 -2
  49. package/server.json +2 -2
  50. package/skills/company-360.md +2 -2
  51. package/skills/contract-profitability.md +2 -2
  52. package/skills/filter-semantics.md +5 -4
  53. package/skills/invoice-export.md +1 -1
  54. package/skills/log-time-entry.md +4 -4
  55. package/skills/slim-reads.md +2 -2
  56. package/skills/ticket-triage.md +1 -1
  57. package/skills/unbilled-work-review.md +3 -3
package/README.md CHANGED
@@ -8,10 +8,10 @@ context into one call, a generic read layer for the long tail, one result envelo
8
8
  packaged playbooks, strong security boundaries, and predictable per-tenant in-process state (pooled
9
9
  client + SDK-owned TTL caches).
10
10
 
11
- > **Package status (2026-10-21):** the runtime dependency `node-autotask` is published to public npm
12
- > at `0.21.0`; the committed `pnpm-lock.yaml` pins that
11
+ > **Package status (2026-10-22):** the runtime dependency `node-autotask` is published to public npm
12
+ > at `0.23.0`; the committed `pnpm-lock.yaml` pins that
13
13
  > release with its integrity hash, so installs resolve deterministically. The dependency is always npm
14
- > (node-autotask pinned exactly at `0.21.0`) — never a `file:`, `git:`, `link:` or workspace reference
14
+ > (node-autotask pinned exactly at `0.23.0`) — never a `file:`, `git:`, `link:` or workspace reference
15
15
  > to a local checkout.
16
16
 
17
17
  > **0.6.0 is a breaking release** (unreleased): tool names, result shapes and configuration all
@@ -21,7 +21,7 @@ client + SDK-owned TTL caches).
21
21
 
22
22
  The **core** toolset (10 tools) is always served; `AUTOTASK_TOOLSETS` adds `service`, `records` and
23
23
  `finance` (or `all`, 18 tools); the operator profile adds two. The whole tool surface (20 tools) is
24
- listed below. Every other operation in the SDK's 1,213-operation registry stays
24
+ listed below. Every other operation in the SDK's 1,216-operation registry stays
25
25
  reachable through the generic layer: `autotask_catalog` finds the key, `autotask_describe` serves
26
26
  its contract, `autotask_read` runs it.
27
27
 
@@ -35,13 +35,18 @@ its contract, `autotask_read` runs it.
35
35
 
36
36
  - `autotask_resolve` turns any identifier into a record: an id or name, `by: "ticket_number"`,
37
37
  `"serial"` or `"email"`, in one `resource` or swept across the five workflow resources;
38
- `match: "contains"` for companies or a sweep. Every match carries `ref`, its human reference.
38
+ `match: "contains"` for companies or a sweep. 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}`.
39
39
  - `autotask_query` is the bounded filtered list over `resource`: `tickets`, `time_entries`,
40
40
  `service_calls`, `contracts`, `time_off_requests` (limit default 50, max 2500).
41
41
  - `autotask_read` runs any read by exact key, or up to 10 at once (`items`). Unbilled work
42
42
  (`companies.unbilledWork`), contract profitability (`contracts.profitability`), aggregates
43
43
  (`operations.aggregate`) and SLA health (`tickets.slaHealthCheck`) run here; `autotask_guide`
44
- holds their recipes.
44
+ holds their recipes. `autotask_read` and `autotask_fetch_many` answer ONE envelope (the next minor,
45
+ breaking): `data = { results: Slot[], ok, failed, budget?, budgetWarning? }`, an array in input
46
+ order even for a single read (`data.results[0].result`). A slot is `{ ok: true, operation, result,
47
+ windowNote? }` or `{ ok: false, operation, error }`; fetch_many slots also echo `resource` and `id`
48
+ (`operation` is `<resource>.get`; a miss is `ok: false` with `NOT_FOUND`). A single read that fails
49
+ still answers the whole-call error envelope.
45
50
  - `autotask_guide` serves the packaged playbooks — the server's skill layer (below).
46
51
 
47
52
  ### `autotask_guide`: the packaged playbooks (the server's skill layer)
@@ -65,7 +70,7 @@ The playbooks are discoverable in both directions:
65
70
  `autotask_describe` for an operation answers with the `playbooks[]` that use it, so a model
66
71
  that lands on an operation is pointed at the recipe.
67
72
 
68
- Composites bound **each part independently** (every part reads its own declared limit argument); a
73
+ Composites bound **each part independently** (every part reads its own declared limit argument; `autotask_company_full_profile` defaults its contact, note, open-ticket and project parts to 10 rows each, `capped: true` says more exist; `fields` projects columns per part); a
69
74
  part miss never fails the call (typed `absent`); only a missing root record fails the composite.
70
75
 
71
76
  **Every result is one envelope** (`structuredContent`, mirrored as JSON in the first text block):
@@ -141,11 +146,46 @@ What the policy can and cannot do:
141
146
  `forbidRemovals`, `requireReason`, UTC `windows`, `duplicateCheck` (required for creates) and
142
147
  `allowUnattended`. Globally: `write.scope.companyIds`, `createdHereWithinDays`, and
143
148
  `limits.writesPerHour`/`writesPerDay`.
149
+ - Moving a time entry or ticket charge to another ticket (#107): list `time_entries.moveToTicket` or
150
+ `ticket_charges.moveToTicket` with an `owner` block, e.g. `{"approval": "none", "allowUnattended": true,
151
+ "owner": {"mode": "createdHere", "days": 7}}` (`createdHere`: this server created the source for this
152
+ principal within 1-14 days; or `{"mode": "resourceIDs", "ids": [..]}` for time entries, whose `resourceID`
153
+ must be listed; a charge has no owning resource). Preview `autotask_write {operation, args: [sourceId,
154
+ toTicketId]}`, then apply. The server creates the copy on the target ticket, verifies it and deletes the
155
+ original (the vendor ignores an update of `ticketID`): the new record gets a new id, create date and
156
+ creator, and notifications may fire. A move into another company or contract always needs a signed
157
+ approval grant; an unfinished earlier move of the same source blocks another until its ledger entry ages
158
+ out (14 days). Billed, attached, approved, closed-target and older-than-14-days sources are refused.
144
159
  - Hard rules no policy overrides: `authenticate.*` is never served; operations the SDK flags
145
160
  `requiresApproval`, and non-read sensitive ones (webhooks, `resources.transferOwnership`), always
146
- need approval; destructive and account-wide operations need it unless `allowUnattended`. In 0.6.0
147
- the approval mode is `policy` only, so an approval-required write answers `needs_approval` and is
148
- never executed. There is no `autotask_delete` (D-12): deletes preview through `autotask_write`.
161
+ need approval; destructive and account-wide operations need it, and `allowUnattended` cannot waive
162
+ that for an operation the hard rules cover. In approval mode `policy` no approver
163
+ exists: an approval-required write answers `needs_approval` with `next: manual_action` (no plan
164
+ handle) and is never executed. Approval mode `external` (`approvalMode: "external"` plus an `approvals`
165
+ block listing ed25519 approver keys, each scoped to operations) is being added in stages (#106): a gated
166
+ preview stores a pending plan (`next: await_approval`, handle, hash, expiry of grant window + 5 minutes) that
167
+ executes only with a valid ed25519 grant file `approvals/grants/<handle>.json` signed by a listed approver
168
+ (apply verifies it; deny rules still win; one use per plan). A human decides with the CLI, run in their own
169
+ terminal as the server's OS user with `AUTOTASK_STATE_DIR` and `AUTOTASK_POLICY_FILE` set as the server has them:
170
+ `autotask-mcp keygen <approver-id> --ops <op,op|*>` (creates a passphrase-sealed ed25519 key, mode 0600, default
171
+ `~/.config/iit-mcp-autotask/approver.key` or `$AUTOTASK_APPROVER_KEY_FILE`, and prints the entry for
172
+ `approvals.approvers`), then `autotask-mcp approvals list|show|approve|reject <handle>`. `show` and `approve`
173
+ recompute the plan hash and snapshot hash from the stored plan and refuse on a mismatch or a changed policy; they
174
+ print only verified fields, and every agent-supplied string (arguments, the agent's reason) is JSON-encoded behind
175
+ `| ` inside a labelled untrusted block with control and bidi characters stripped. `approve` and `reject` need you to
176
+ type the first 8 characters of the plan hash and the key passphrase; the passphrase is read from `/dev/tty` only
177
+ (never the environment, arguments, a pipe or a file; there is no `--yes`), so the command refuses without a
178
+ 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
179
+ pty, and on stdio the sealed key file can be attacked offline by anything running as the same user. On HTTP run it
180
+ where the state dir lives (for example `docker exec -it` as the server's user). On stdio this is a
181
+ guardrail against a mistaken agent, not a boundary against an adversarial agent running as the same OS
182
+ user; it is a real boundary only on HTTP with the server, its key material and the policy file isolated
183
+ from the agent. Any edit to the policy file (including `approvals`) changes the policy hash and voids
184
+ every outstanding plan. `data.manual` carries a fingerprint of the record (ids, ticket,
185
+ start time, hours, resource — numbers and instants only, never vendor text) and, when the pinned
186
+ SDK provides `operations.uiLink`, a clickable Autotask deep link; a person finishes the job in
187
+ Autotask. A policy that sets `approval: "none"` on such an operation changes nothing: boot logs a
188
+ warning and `doctor` prints it. There is no `autotask_delete` (D-12): deletes preview through `autotask_write`.
149
189
  - The company scope is read from the live records the write touches (the target, its parent, the
150
190
  ticket, project, task or contract it references, and any `companyID` it sets), never from names
151
191
  or titles. A reference that cannot be read is refused.
@@ -162,7 +202,7 @@ Outcomes the caller acts on: `ok`/`proceed`; `needs_input`/`supply_input` (`miss
162
202
  field); `needs_approval`/`await_approval`; `denied`/`stop`; `STATE_DRIFT` or `PLAN_EXPIRED` →
163
203
  `repreview`; a failed write: `WRITE_FAILED` with `stop` (a deterministic rejection; it replays,
164
204
  fix the input and use a new key) or `RATE_LIMITED` with `retry` (nothing was written: apply the
165
- same plan again after the wait); an unknown outcome (`WRITE_OUTCOME_UNKNOWN`,
205
+ 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`,
166
206
  `verify_then_repreview`): that plan is never sent again, and re-applying it or re-previewing under
167
207
  the same key only replays `unknown`. Re-read the target; if the write is absent, preview again under
168
208
  a new `idempotency_key` (a new attempt) after the 10-minute cooldown; its duplicate query refuses
@@ -262,9 +302,13 @@ Three per-call options change the result form:
262
302
  | `untrusted_wrapper: false` | skips the `<untrusted_content>` wrapping for that call only; the result then carries `untrustedWrapper: 'disabled'` and the human text says so. Absent (the default) wraps exactly as before. |
263
303
  | `output_path: '<relative path>'` | **stdio transport only**: walks the FULL match set with the resource's own query iterator, writes the envelope JSON to a file under `AUTOTASK_OUTPUT_DIR`, and returns a summary (`outputPath`, `columns`, `rowCount`, the first rows, `bytes`) instead of the table. |
264
304
 
265
- `output_path` is deliberately strict: the deployment must set an **absolute** `AUTOTASK_OUTPUT_DIR`
266
- that already exists and is not the filesystem root (the feature refuses when it is unset or missing —
267
- the server never creates the root), and the value must be a path **relative** to that root that names
305
+ `output_path` is deliberately strict: the deployment sets an **absolute** `AUTOTASK_OUTPUT_DIR`
306
+ that already exists and is not the filesystem root (a configured root that is missing is refused —
307
+ the server never creates it). When it is unset the root defaults to `output/` under `AUTOTASK_STATE_DIR`
308
+ (the existing, operator-configured state dir; the server creates that one subdirectory and nothing
309
+ above it; with neither variable set the feature refuses). `format: 'tsv'` with `output_path` writes the
310
+ TSV table to the file (the result carries the path, never the table); `latest` and `note_chars` still
311
+ cannot combine with it. And the value must be a path **relative** to that root that names
268
312
  a file (a trailing separator, an absolute path, a NUL byte, and a target that is an existing
269
313
  directory are all refused). **Nothing outside the root is ever created,
270
314
  file or directory**: the deepest existing ancestor of the target is canonicalised (`realpath`) and
@@ -480,7 +524,7 @@ non-zero on an invalid config (fail-fast) instead of failing at the first tool c
480
524
  | `AUTOTASK_CACHE_PRESET` | no | `on` | per-tenant in-process caches (see [Per-tenant pool & cache](#per-tenant-pool--cache)): `on` (default) sends no cache blocks — the SDK's default-on caches apply to each pooled tenant client (read ~5 min + the plan-derived reference table, field ~5 min, resource-name ~1 h); `off` opts every SDK cache channel out explicitly (every read re-dials the vendor) |
481
525
  | `AUTOTASK_COMPOSITE_NAMES` | no | `off` | opt-in name/label siblings on the four SDK-backed composites (`tickets.fullContext`, `companies.fullProfile`, `configuration_items.profile`, `projects.getContext`) — reuses the SDK's existing caches (no new cache); `off` = byte-identical output to today; `on` adds the name siblings + the top-level `names` section, bounded by the SDK name-read budget (default 20 cold wire reads, lowerable only) |
482
526
  | `AUTOTASK_FIELD_CACHE_TTL_MS` | no | unset (SDK 5 min) | pass-through to the SDK's field-metadata cache TTL (positive integer ms), sent as the scalar `fieldCache` block on the `on` preset path only — the server holds no TTL value of its own: unset or invalid (non-integer, <= 0) sends no `fieldCache` block and the SDK default (5 min) applies; `off` keeps its explicit opt-out; `fieldCatalog` `fresh: true` still bypasses the cache |
483
- | `AUTOTASK_OUTPUT_DIR` | no | unset | the ONE directory the `output_path` option may write into (the bounded query tools and the invoice export; stdio transport only). Unset = the feature refuses; must be absolute, must already EXIST (the server never creates the root) and must not be the filesystem root. A caller path is relative to this root, and the root and target are canonically resolved (`realpath`) so a traversal or symlink escape is refused — with nothing created outside the root. See [Query results](#query-results-tsv-output_path-and-the-saved-file-shape) |
527
+ | `AUTOTASK_OUTPUT_DIR` | no | unset | the ONE directory the `output_path` option may write into (the bounded query tools and the invoice export; stdio transport only). Unset = defaults to `output/` under `AUTOTASK_STATE_DIR` (the feature refuses when neither is set); must be absolute, must already EXIST (the server never creates the root) and must not be the filesystem root. A caller path is relative to this root, and the root and target are canonically resolved (`realpath`) so a traversal or symlink escape is refused — with nothing created outside the root. See [Query results](#query-results-tsv-output_path-and-the-saved-file-shape) |
484
528
  | `AUTOTASK_RATE_LIMIT_PER_HOUR` | no | 8000 (agent profile) | override the SDK's local hourly budget (never above the vendor cap; `null`/off is rejected at boot — it would void the budget guard) |
485
529
  | `AUTOTASK_MAX_CONCURRENT` | no | 3 (agent profile) | override the SDK's local concurrency bound |
486
530
  | `AUTOTASK_TIMEOUT_MS` | no | 20000 (agent profile) | whole-call budget |
@@ -719,7 +763,7 @@ independent review with its resolutions, [`docs/reviews/0.6.0.md`](docs/reviews/
719
763
 
720
764
  ## SDK dependency
721
765
 
722
- `iit-mcp-autotask` depends on **`node-autotask` from npm at exactly `0.21.0`** (an exact pin —
766
+ `iit-mcp-autotask` depends on **`node-autotask` from npm at exactly `0.23.0`** (an exact pin —
723
767
  no range operator; subpaths `.` / `/capabilities` / `/operations` / `/resources` / `/types` /
724
768
  `/pool`) — the only **vendor** dependency; the other two runtime dependencies are
725
769
  `@modelcontextprotocol/server` (the MCP framework) and `zod` (schema validation). It never
@@ -729,7 +773,7 @@ user — the published tarball ships no lockfile) must resolve to the release th
729
773
  reviewed against, or the plan-hash boot check refuses to start.
730
774
 
731
775
  **Publishing `node-autotask` is the maintainer's action, done manually.** It is published on the
732
- public registry at `0.21.0` (published 2026-10-20); the
776
+ public registry at `0.23.0` (published 2026-10-21); the
733
777
  exact pin and the committed `pnpm-lock.yaml` (with `pnpm-workspace.yaml`) pin the real published
734
778
  tarball with its integrity hash, so installs resolve to exactly that release — the lockfile
735
779
  freezes the pnpm/git/Docker paths, the pin freezes the npm/npx paths. The docs are written against this
@@ -823,7 +867,7 @@ physical assets (Autotask models them as Configuration Items — covered).
823
867
 
824
868
  | # | Item | Status |
825
869
  |---|------|--------|
826
- | B1 | **Project phase & task lists** (W7 sub-capability): no `project_tasks`/`project_phases` operations in the SDK's 1,213-op registry. W7 ships with project status + context; phase/task lists excluded and documented. Classification (SDK gap vs vendor API-version limit) is decided by recon's spec-side check (A2). | blocked — documented |
870
+ | B1 | **Project phase & task lists** (W7 sub-capability): no `project_tasks`/`project_phases` operations in the SDK's 1,216-op registry. W7 ships with project status + context; phase/task lists excluded and documented. Classification (SDK gap vs vendor API-version limit) is decided by recon's spec-side check (A2). | blocked — documented |
827
871
  | B2 | **Time-entry writes on ww29/australia-nz for API-only users**: vendor 500 (live-verified). Time-entry creation works where the API user is a real resource, through the operator profile (`log-time-entry`); on ww29 the write is classified `failed` (a 500 with a vendor `errors[]` body), never `unknown`. | zone limitation |
828
872
  | B3 | **`time_entries.getPosted`/`getUnposted` 500 on ww29** (no posted-state filter on the zone's entity). Reachable via the generic layer; `postedWithin` is the zone-robust alternative. | zone limitation |
829
873
  | B4 | **CI create on tenants without products** (ww29 product wall: `products.count = 0`): `configuration_items.create` requires an existing `productID`. No dedicated CI-create in v1; the wall is documented on the generic side. | zone limitation |
package/capabilities.json CHANGED
@@ -70,7 +70,7 @@
70
70
  },
71
71
  {
72
72
  "id": "autotask.platform.catalog",
73
- "description": "Find the exact operation key for anything without a dedicated tool: ranked search (q) over the 1,213 registry operations, with effect and whether this deployment serves it. No vendor call.",
73
+ "description": "Find the exact operation key for anything without a dedicated tool: ranked search (q) over the 1,216 registry operations, with effect and whether this deployment serves it. No vendor call.",
74
74
  "mcpPrimitive": "tool",
75
75
  "toolName": "autotask_catalog",
76
76
  "type": "read",
@@ -118,7 +118,7 @@
118
118
  },
119
119
  {
120
120
  "id": "autotask.discovery.resolve",
121
- "description": "Find the record behind an identifier: id or name, ticket number, configuration item serial number, contact email (by), in one resource or swept across several. Exact by default; contains for companies or a sweep. Matches carry ref.",
121
+ "description": "Find the record behind an identifier: id or name, ticket number, configuration item serial number, contact email (by), in one resource or swept across several. Exact by default; contains for companies or a sweep (compact; limit = total).",
122
122
  "mcpPrimitive": "tool",
123
123
  "toolName": "autotask_resolve",
124
124
  "type": "read",
@@ -150,7 +150,7 @@
150
150
  },
151
151
  {
152
152
  "id": "autotask.discovery.fetch_many",
153
- "description": "Fetch up to 500 records by numeric id across mixed resources in one call: items: [{resource, id, fields?}]. A miss is a typed error in its slot; records carry their human reference.",
153
+ "description": "Fetch up to 500 records by numeric id across mixed resources in one call: items: [{resource, id, fields?}]. Answers data.results[{ok, operation, result|error}], ok, failed (same as autotask_read); a miss is a typed NOT_FOUND slot.",
154
154
  "mcpPrimitive": "tool",
155
155
  "toolName": "autotask_fetch_many",
156
156
  "type": "read",
@@ -198,7 +198,7 @@
198
198
  },
199
199
  {
200
200
  "id": "autotask.company.full_profile",
201
- "description": "One company with locations, contacts, notes, open tickets and projects. Open = status not 5 (always filtered, so empty is a true zero). Missing parts degrade to typed absent.",
201
+ "description": "One company with locations, contacts, notes, open tickets and projects, 10 rows per part (capped: true = more exist; raise the limit). fields {part:[names]} trims columns. Open = status not 5. Missing parts degrade to typed absent.",
202
202
  "mcpPrimitive": "tool",
203
203
  "toolName": "autotask_company_full_profile",
204
204
  "type": "read",
@@ -380,7 +380,7 @@
380
380
  "autotask_describe",
381
381
  "autotask_read"
382
382
  ],
383
- "backingRegistryOperations": 1213,
383
+ "backingRegistryOperations": 1216,
384
384
  "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
385
  },
386
386
  "schemas": {}
@@ -0,0 +1,112 @@
1
+ /**
2
+ * approvals.ts — the `autotask-mcp approvals` and `keygen` subcommands (issue #106, approvalMode external).
3
+ *
4
+ * A human runs these in their own terminal as the server's OS user. They never start a server and never
5
+ * call `openStateStore` (it registers a holder, takes locks, rewrites the ledger and prunes plans): they
6
+ * read `state.key`, decrypt the pending plan, and write ONE file, `approvals/grants/<handle>.json`, with
7
+ * exclusive create so the first decision is final. The server audits the grant when it consumes it.
8
+ *
9
+ * What is shown is re-derived, never stored text: the plan hash and snapshot hash are recomputed from the
10
+ * stored fields and the command refuses on a mismatch or when the policy changed. Every agent- or
11
+ * vendor-sourced string is stripped of control/bidi characters, JSON-encoded, stripped of control/bidi characters, capped, and printed behind a
12
+ * `|` prefix inside a block labelled untrusted, so it cannot pose as a verified line.
13
+ *
14
+ * Guardrails, not a boundary: the passphrase is read from /dev/tty only (no env, argv, stdin, flag or file),
15
+ * which stops accidents, not an agent that drives a pty. On stdio the encrypted key file can be brute-forced
16
+ * offline by anything running as the same user.
17
+ */
18
+ import { type KeyObject } from 'node:crypto';
19
+ import { type LoadedPolicy } from './write/policy-file.js';
20
+ import { type PlanRecord } from './write/store.js';
21
+ /** scrypt cost floor for the approver key file: N = 2^17, r = 8 (Node's default maxmem refuses this, so it is set). */
22
+ export declare const SCRYPT_N: number;
23
+ export declare const SCRYPT_R = 8;
24
+ /** How the commands talk to the human: only the real implementation touches /dev/tty. */
25
+ export interface Prompter {
26
+ /** Show text to the human. */
27
+ say(text: string): void;
28
+ /** Read one line with echo. */
29
+ ask(question: string): string;
30
+ /** Read one line without echo (a passphrase). */
31
+ secret(question: string): string;
32
+ }
33
+ export declare class ApprovalsError extends Error {
34
+ }
35
+ /** The controlling terminal, or an error: no tty means no prompt, and the command exits non-zero. */
36
+ export declare function ttyPrompter(): Prompter & {
37
+ close(): void;
38
+ };
39
+ interface KeyFile {
40
+ v: 1;
41
+ /** `ed25519:` + base64url of the raw public key (what goes in the policy file). */
42
+ publicKey: string;
43
+ kdf: {
44
+ N: number;
45
+ r: number;
46
+ p: 1;
47
+ salt: string;
48
+ };
49
+ iv: string;
50
+ tag: string;
51
+ ct: string;
52
+ }
53
+ /** The key file's default path: `AUTOTASK_APPROVER_KEY_FILE`, else `~/.config/iit-mcp-autotask/approver.key`. */
54
+ export declare const keyFilePath: (env: Record<string, string | undefined>) => string;
55
+ export declare function sealKey(privateKey: KeyObject, publicKey: string, passphrase: string): KeyFile;
56
+ /** Decrypt a key file; refuses weak kdf parameters (the file is attacker-writable on stdio) and a wrong passphrase. */
57
+ export declare function openKey(file: KeyFile, passphrase: string): KeyObject;
58
+ /**
59
+ * An untrusted value for display: every string (and key) stripped of control/bidi characters, then the value JSON-encoded
60
+ * ONCE, so a string stays quoted while null, numbers, booleans, arrays and objects print as themselves (`null` and
61
+ * `"null"` are different writes and must look different). Capped; a cut value is counted in `cut`.
62
+ */
63
+ export declare const untrusted: (value: unknown, cap?: number, cut?: {
64
+ n: number;
65
+ }) => string;
66
+ /** The plan's state dir files, and a pending plan checked against every hash that can be recomputed. */
67
+ export interface Verified {
68
+ plan: PlanRecord;
69
+ loaded: LoadedPolicy;
70
+ }
71
+ export interface Env {
72
+ stateDir: string;
73
+ policyFile: string;
74
+ }
75
+ export declare function envFrom(env: Record<string, string | undefined>): Env;
76
+ /** Load the plan and refuse unless every recomputable hash matches and the policy is the one it was previewed under. */
77
+ export declare function verifyPlan(env: Env, handle: string, now: number): Verified;
78
+ /** One line saying what is being decided: repeated at the confirmation prompt so it is the last thing read. */
79
+ export declare const summaryLine: (plan: PlanRecord) => string;
80
+ /**
81
+ * The hash-verified description of a plan. Order: verified header, the agent's reason, the arguments (signed content:
82
+ * a value cut for display counts in `truncated` and blocks signing), then the records as they stand (context).
83
+ * Untrusted strings are stripped, JSON-encoded and printed behind `| `.
84
+ */
85
+ export declare function describePlan(v: Verified, full?: boolean): {
86
+ lines: string[];
87
+ truncated: number;
88
+ };
89
+ export declare function listPending(env: Env, now: number): string[];
90
+ /** Sign and write a decision. Exclusive create: the first decision for a handle is final. */
91
+ export declare function decide(opts: {
92
+ env: Env;
93
+ handle: string;
94
+ decision: 'approve' | 'reject';
95
+ prompt: Prompter;
96
+ keyFile: string;
97
+ now: () => number;
98
+ full?: boolean;
99
+ }): string;
100
+ /** Generate an approver key, seal it under a passphrase, and return the policy entry to paste into `approvals.approvers`. */
101
+ export declare function keygen(opts: {
102
+ id: string;
103
+ ops: string[];
104
+ prompt: Prompter;
105
+ keyFile: string;
106
+ }): string;
107
+ export declare const APPROVALS_USAGE = "autotask-mcp approvals list\nautotask-mcp approvals show <handle> [--full]\nautotask-mcp approvals approve <handle> [--full]\nautotask-mcp approvals reject <handle> [--full]\nautotask-mcp keygen <approver-id> --ops <op,op|*>\n\nRun these yourself, in a terminal, as the server's OS user, with AUTOTASK_STATE_DIR and AUTOTASK_POLICY_FILE\nset as the server has them. The passphrase is read from /dev/tty only. Key file: $AUTOTASK_APPROVER_KEY_FILE,\nelse ~/.config/iit-mcp-autotask/approver.key.";
108
+ /** Run a subcommand; returns the process exit code. `argv` is everything after `autotask-mcp`. */
109
+ export declare function runApprovals(argv: string[], env: Record<string, string | undefined>, out: (t: string) => void, prompter?: () => Prompter & {
110
+ close(): void;
111
+ }, now?: () => number): number;
112
+ export {};