# Work tools

Optional `@arnilo/prism-work/connectors` subpath: identity-scoped Microsoft 365 and Google Workspace connectors. Host-pinned CLI binaries or pinned HTTP adapters; hard-coded operation maps; draft-then-approve mutations; side-effect idempotency; shared mail/calendar/file/task result shapes. HTTP file gets persist untrusted bytes to a host artifact store or contained filesystem, never the transcript.

## When to use

Use when agents must read or mutate tenant mail/calendar/files/tasks through a host-pinned enterprise CLI or a pinned HTTP adapter — not through model-built shell strings, model-supplied URLs, or generic Graph/Discovery free-form calls.

## Install

```bash
npm install @arnilo/prism @arnilo/prism-work
# host separately:
#   npm i -g @pnp/cli-microsoft365
#   npm i -g @googleworkspace/cli
```

## API

```ts
import {
  createWorkTools,
  createMicrosoft365HttpAdapter,
  createGoogleWorkspaceCliAdapter,
  createGoogleWorkspaceHttpAdapter,
  createMemoryIdempotencyStore,
} from "@arnilo/prism-work/connectors";
import { createOAuthWorkTokenProvider } from "@arnilo/prism-core/credentials/node";
// or: import { createGoogleWorkspaceCliAdapter } from "@arnilo/prism-work/connectors/google-workspace";

const microsoft365 = createMicrosoft365HttpAdapter({
  identity,
  tokenProvider: createOAuthWorkTokenProvider({ provider: m365OAuth, store, envVar: "M365_ACCESSTOKEN" }),
  accessEnvVar: "M365_ACCESSTOKEN",
});
// Or retain createMicrosoft365CliAdapter({ binary, configDir, identity }) for host-pinned m365.

const googleWorkspace = createGoogleWorkspaceHttpAdapter({
  identity,
  tokenProvider: createOAuthWorkTokenProvider({ provider: gwsOAuth, store, envVar: "GOOGLE_ACCESS_TOKEN" }),
  accessEnvVar: "GOOGLE_ACCESS_TOKEN",
  // allowedOps: add docs.create / sheets.create / slides.create when gated
});
// Or retain createGoogleWorkspaceCliAdapter({ binary, configDir, identity }) for host-pinned gws.

const tools = createWorkTools({
  microsoft365,
  googleWorkspace,
  idempotencyStore: createMemoryIdempotencyStore(),
  approval: { isApproved: ({ draftId }) => hostHasApproved(draftId) },
  externalRecipients: { allow: (addr) => addr.endsWith("@contoso.com") },
  scanAttachment: ({ bytes }) => hostScan(bytes), // required before file-get persistence
  artifacts: hostWorkArtifacts, // creates ArtifactBodyRef values and owns body storage
  filesystem: containedFilesystem, // optional destination/source for work-sandbox files
});
```

List/get tools return shared `WorkPage` / `WorkMailMessage` / `WorkCalendarEvent` / `WorkFileItem` / `WorkTaskItem` shapes (`untrusted: true`) via package normalizers — provider-specific fields are not hidden; they are mapped onto the common denominator.

### Hard-coded Microsoft 365 ops

Verified against [CLI for Microsoft 365](https://pnp.github.io/cli-microsoft365/) (2026-07-23):

| Prism op | CLI |
| --- | --- |
| `mail.list` | `m365 outlook message list --output json` |
| `mail.get` | `m365 outlook message get --output json --id …` |
| `mail.send` | `m365 outlook mail send --output json --to … --subject … --bodyContents …` |
| `calendar.list` | `m365 outlook event list --output json` |
| `calendar.add` | `m365 outlook event add --output json --subject … --start … --end …` |
| `file.list` | `m365 file list --output json --webUrl … --folderUrl …` |
| `file.get` | HTTP only: `GET /me/drive/items/{id}/content` on `graph.microsoft.com` |
| `file.add` | `m365 file add --output json --folderUrl … --filePath …` |
| `file.copy` | `m365 file copy --output json --webUrl … --sourceUrl … --targetUrl …` (draft-then-approve) |
| `file.share` | `m365 spo file sharinglink add` (`--scope organization` only) |
| `todo.*` / `planner.*` | capability-gated via `allowedOps` |

### Hard-coded Google Workspace ops

Verified against [`@googleworkspace/cli` / `gws`](https://github.com/googleworkspace/cli) (2026-07-24):

| Prism op | CLI |
| --- | --- |
| `mail.list` | `gws gmail users messages list --params … --fields …` |
| `mail.get` | `gws gmail users messages get --params …` |
| `mail.send` | `gws gmail +send --to … --subject … --body …` |
| `calendar.list` | `gws calendar events list --params … --fields …` |
| `calendar.add` | `gws calendar events insert --params … --json …` |
| `file.list` | `gws drive files list --params … [--page-all]` (NDJSON when paginated) |
| `file.get` | HTTP only: `GET /drive/v3/files/{id}?alt=media` on `www.googleapis.com` |
| `file.add` | `gws drive files create --json … --upload …` |
| `file.share` | `gws drive permissions create` (`type=domain\|user` only; `anyone` denied) |
| `task.*` | `gws tasks tasks list\|insert\|patch` |
| `docs.create` / `sheets.create` / `slides.create` | capability-gated via `allowedOps` |
| `docs.update` / `sheets.update` / `slides.update` | capability-gated fixed-shape updates; never free-form batch requests |

### Microsoft 365 HTTP adapter

`createMicrosoft365HttpAdapter()` uses host-provided OAuth tokens only in the `Authorization` header and pinned fetch against `graph.microsoft.com`. Its fixed map covers Outlook messages/events, draft-then-approve OneDrive copy/sharing, and capability-gated To Do/Planner tasks. `ensureReady()` performs a bounded Graph `/me` request. `m365_file_get` accepts only an item ID and uses fixed `/me/drive/items/{id}/content`; it writes untrusted bytes to an artifact and/or contained filesystem after `scanAttachment`, returning only `{ artifact?, path?, byteLength, contentHash, untrusted: true }`. File list/upload/copy accepts an HTTPS Graph Drive-item URL; arbitrary SharePoint links are rejected rather than resolved with an extra request. The CLI adapter remains available for CLI-specific SharePoint paths.

### Google Workspace HTTP adapter

`createGoogleWorkspaceHttpAdapter()` uses host-provided OAuth tokens only in the `Authorization` header and pinned fetch against `docs.googleapis.com`, `gmail.googleapis.com`, `sheets.googleapis.com`, `slides.googleapis.com`, `www.googleapis.com`, and `tasks.googleapis.com`. Its operation map is fixed: Gmail messages, Calendar events, Drive files/permissions, Google Tasks, and capability-gated native Docs/Sheets/Slides creates plus draft-then-approve fixed-shape updates. Docs accepts only replace-text and insert-text requests; Sheets PUTs a string matrix with `valueInputOption=RAW`; Slides accepts only shape text insertion. No tool accepts a free-form `requests[]`. `ensureReady()` performs a bounded Gmail profile request; `gws_file_get` accepts only an item ID and uses fixed `Drive files.get?alt=media`, persisting untrusted bytes exactly like `m365_file_get`. `file.add` accepts a host-local path, `ArtifactBodyRef`, or contained sandbox path; its approved draft binds the content SHA-256 and rejects changed bytes. The CLI adapter remains available.

Startup: M365 CLI uses `version --output json`; M365 HTTP `ensureReady()` uses Graph `/me`; GWS CLI uses `--version`; GWS HTTP `ensureReady()` uses Gmail profile. Forbidden: `login`, `setup`, `auth`, `schema`, `doctor`, `--debug`, `--verbose`, credentials in argv, anonymous share, model-supplied command strings / URLs / free-form Discovery.

### Draft → approve → execute (0.7.0, R02)

Mutation tools (`*_mail_draft_send`, `*_draft_*`) create an in-adapter draft and return `{ status: "pending_approval", draftId, revision, payloadDigest }` until the host approval gate grants permission.

In Prism 0.7.0, draft lifecycles are durably managed:

- **Exact revision binding**: Every draft carries an integer `revision` (starts at 1) and a deterministic canonical `payloadDigest` (`sha256:<hex>`). Approvals bind strictly to `{ draftId, revision, payloadDigest, identityKey, approvedAt, expiresAt, policyRevision }`.
- **Durable persistence across restarts**: When adapters are configured with `checkpoints: CheckpointStore` (e.g. `createPostgresEnterpriseState({ pool }).checkpoints` or `createMemoryCheckpointStore()`), drafts are stored under namespace `prism.work.draft`. Drafts survive process restarts; a worker process can resume an exact draft revision approved in a prior process or via a delayed human-in-the-loop review.
- **Edits invalidate approval**: Any mutation or update to a draft increments `revision`, recalculates `payloadDigest`, clears any previous `approval`, and resets `status` to `pending_approval`. Prior approvals cannot execute a modified draft.
- **Resuming approved drafts**: Mutation tools accept `{ draftId, revision }` without requiring callers to re-supply the full payload. The tool loads the stored draft, validates approval status and digest, reauthorizes immediately before execution, and executes the effect.
- **Idempotent duplicate approvals**: Re-approving an approved draft with the same approval object is idempotent. Submitting an approval with a mismatched revision or payload digest is rejected with `ERR_PRISM_WORK_DRAFT_STALE` or `ERR_PRISM_WORK_DRAFT_DIGEST`.
- **Ambiguous failure handling**: If a connector call fails ambiguously after dispatch, both the idempotency record and the draft are marked `unknown`. Re-running with that draft ID or idempotency key fails closed (`ERR_PRISM_WORK_IDEMPOTENCY_UNKNOWN`) and never auto-replays without explicit operator reconciliation.
- **File-byte binding**: `*_file_draft_upload` accepts a host-local path, `ArtifactBodyRef`, or contained sandbox path. Its payload digest includes `contentHash`; execution re-hashes bytes and rejects a changed source. Artifact reads verify their hash/size through `ArtifactBodyStore`.
- **Optional body offloading**: Supplying `bodies: ArtifactBodyStore` automatically stores large draft message/file bodies in the object store with an `ArtifactBodyRef` recorded on the draft metadata.

### Durable idempotency (0.0.23)

`createMemoryIdempotencyStore()` remains for tests and a single process. For production replicas, use `createPostgresEnterpriseState({ pool }).workIdempotency`. It changes the old `get`/`put` replay abstraction to explicit async state transitions:

| Observable state | Meaning / host action |
| --- | --- |
| absent | `begin()` atomically acquires the first claim. |
| `in_progress` | Another worker owns the claim; do not dispatch a second connector effect. |
| `completed` | Return the bounded stored `{ draftId, resourceId? }` duplicate summary. |
| `failed_retryable` | A later `begin()` may reclaim it within the capped attempt policy. |
| `failed_terminal` | Do not retry; surface the bounded failure. |
| `unknown` | External result is ambiguous; reconcile with the connector/operator through `resolveUnknown()`. Never auto-replay. |

Call `begin({ identity, key, op })` **before** the external effect. After it succeeds, call `complete`, `fail`, or `markUnknown` with the returned claim token and version. The connector effect stays outside the database transaction, so this is claim-before-effect/deduplication—not exactly-once delivery. Claims default to 15 minutes (hard 60 minutes); expired claims transition to `unknown`; attempts default to 3 (hard 5). Stored rows contain no request body, token, raw provider response, or unrestricted payload.

Durable adapters reject with the portable codes `ERR_PRISM_WORK_IDEMPOTENCY` (a claim or payload the adapter refuses) and `ERR_PRISM_WORK_IDEMPOTENCY_CONFLICT` (a lost race, a stale claim token, or a transition out of order). Match on `error.code`: the error *class* is adapter-specific — the in-memory store raises `WorkToolError`, `createPostgresEnterpriseState(...).workIdempotency` raises `EnterprisePostgresError`, because `@arnilo/prism-core` cannot depend on `@arnilo/prism-work` at runtime — so an `instanceof` check that worked against the pre-move import path will silently stop matching. `packages/prism-core/src/enterprise/postgres/__tests__/work-idempotency.integration.test.ts` drives both adapters through the same conflict scenarios and asserts they report the same codes.

## Subprocess environment isolation (0.2.0, plan 020 Task 3)

`createCliRunner` never inherits the host environment. The child process receives only:

1. **Fixed platform base** — allow-listed locale/system keys copied from the host: `PATH`, `LANG`, `LC_ALL`, `TZ`, `SYSTEMROOT`/`SystemRoot`, `TEMP`, `TMP`, `PATHEXT`, `COMSPEC`. Nothing else from `process.env` crosses the boundary, so unrelated ambient variables (e.g. `PRISM_PROOF_SECRET`) cannot reach the CLI. Additions to the list are deliberate one-line allow-list changes.
2. **Explicit host env** — non-secret values passed via the `env` option (e.g. `{ LANG: "C.UTF-8" }`).
3. **Late-bound per-identity token env** — the `tokenProvider` result, merged per call; never argv, never model context.
4. **Forced reserved controls** — `HOME` is always the isolated `configDir` and `CLIMICROSOFT365_DISABLETELEMETRY` is always `"1"`; neither the explicit map nor the token layer can override them (any attempt fails closed with `ERR_PRISM_WORK_ENV` before spawn).

Environment maps are validated before spawn: NUL-free, `[A-Za-z_][A-Za-z0-9_]*` names, string values, no case-insensitive duplicate or reserved keys (Windows canonicalizes PATH/system key casing so Node's first-lexicographic-key behavior cannot select an attacker-controlled duplicate), and fixed caps of 64 names / 64 KiB total (`ERR_PRISM_WORK_LIMIT`).

### Host-pinned absolute paths

`binary` and `configDir` must be **absolute** paths (`path.isAbsolute`); empty, relative, or NUL-containing values are rejected at construction with `ERR_PRISM_WORK_BINARY` / `ERR_PRISM_WORK_CONFIG` before any spawn.

### Migration (0.1.7 → 0.2.0)

- Any host env your CLI needed beyond the fixed base must move into the explicit `env` map (non-secret) or the per-identity token layer (secrets).
- Relative `binary`/`configDir` values now fail at construction; resolve them to absolute paths.
- Per-call `runOpts.env` keys colliding case-insensitively with `HOME` or the telemetry-disable control now fail closed instead of being silently overridden.
- Output capture is linear: chunks are accumulated in an array with one final `Buffer.concat`, and the process is killed/rejected before bytes beyond the stdout/stderr caps are retained.

## Limits

| Resource | Default / hard |
| --- | ---: |
| Pagination pages | 20 / 100 |
| Items / aggregate | 50/500 ; 200/2000 |
| Body / stdout | 256 KiB–2 MiB / 2–16 MiB |
| Download / upload file | 10 MiB / 50 MiB |
| Process wall time | 60 s / 10 min |
| Concurrent CLI / identity | 2 / 8 |

## Tool effects

Approved mutations require core-derived `context.idempotencyKey` and a configured store (`effect: external_mutation/tool_managed`). Model-supplied idempotency keys are ignored. Ambiguous connector outcomes stay `unknown` — never auto-replayed (not exactly-once). See [tool effects](tool-effects.md).

## Security

- Require host-verified `AgentIdentity`; no cross-identity configDir reuse.
- Connector tokens: a `tokenProvider` resolves a per-identity access token only at the connector edge — into CLI env for CLI adapters or an `Authorization` header for HTTP adapters, never argv or model context. A missing/expired/revoked/cross-identity/wrong-tenant token fails the call closed before dispatch. Refresh is late-bound and single-flighted per account. Build one with `createOAuthWorkTokenProvider()` from `@arnilo/prism-core/credentials/node`.
- External mail recipients fail closed unless `externalRecipients.allow` returns true.
- Anonymous / `anyone` sharing denied.
- File gets accept IDs, never URLs; their response stream is cancelled at `maxFileBytes`, scanned before persistence, and returned only as artifact/path metadata with `untrusted: true`.
- CLI stdout/stderr capped (linear chunk capture, killed/rejected before bytes beyond the cap are retained); NDJSON page streams strictly parsed and page-capped; process killed on timeout/abort/overflow.
- Subprocess environment isolated (0.2.0): fixed allow-listed base + explicit `env` + late-bound token env; `HOME`/telemetry controls forced; reserved/duplicate/NUL/over-cap env and non-absolute binary/configDir fail before spawn. See [Subprocess environment isolation](#subprocess-environment-isolation-020-plan-020-task-3).

## Related

- [Enterprise PostgreSQL state](enterprise-postgres-state.md): durable claim/CAS store, cleanup, and operator reconciliation.
- [Work connectors](work-connectors.md)
- [Agent identity](agent-identity.md)
- [Host security](host-security.md)
- [Credential storage](credential-storage.md)
