Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
7a8e1ef
Add PagerDuty gatekeeper
ndisidore Nov 6, 2026
9d1e137
Add PagerDuty event hooks
ndisidore Nov 6, 2026
bb371b2
Address PagerDuty gatekeeper review findings
ndisidore Nov 6, 2026
472896a
Address PagerDuty hooks review findings
ndisidore Nov 6, 2026
58dfd5e
Address second PagerDuty review
ndisidore Nov 6, 2026
89483ef
Name PagerDuty action providers by kind
ndisidore Nov 6, 2026
3026ed1
Fence PagerDuty hook deliveries to the event's account
ndisidore Nov 6, 2026
c1909e5
Address PagerDuty PR review findings
ndisidore Nov 6, 2026
3b56719
Re-check PagerDuty scope with fresh reads at apply
ndisidore Nov 6, 2026
959ea6c
Handle resolved acks, text alert details and shift-based PagerDuty sc…
ndisidore Nov 6, 2026
ed8bd4b
Probe PagerDuty schedule generation without parameters
ndisidore Nov 6, 2026
c82921b
Let kit cursors re-derive provider rows as they are served
ndisidore Nov 6, 2026
e91bdfb
Simulate pending PagerDuty changes in incident and on-call listings
ndisidore Nov 7, 2026
1f32663
Simulate PagerDuty's defaults for incidents a gadget opens
ndisidore Nov 7, 2026
63bf2ba
Simplify the PagerDuty gatekeeper
ndisidore Nov 7, 2026
8e4bf12
Derive a PagerDuty capability's reach from its scope
ndisidore Nov 7, 2026
323adb4
Check a PagerDuty action against the capability that staged it
ndisidore Nov 7, 2026
e746b70
Say which pending PagerDuty changes reads don't show
ndisidore Nov 7, 2026
72c3d12
Simulate PagerDuty's acknowledge and resolve side effects
ndisidore Nov 8, 2026
24f3f65
List a PagerDuty team's incidents by its current services
ndisidore Nov 8, 2026
7fdd908
Let a kit cursor refresh what its overlay reads before every page
ndisidore Nov 8, 2026
a59acf1
Re-check a PagerDuty team's services before every listing page
ndisidore Nov 8, 2026
5c16ecb
Name PagerDuty overrides from their editable view; bound team listing…
ndisidore Nov 8, 2026
2c4bc3f
Record the kit cursor's overlay and beforePage hooks in the plan
ndisidore Nov 8, 2026
0575e61
Simplify the PagerDuty gatekeeper
ndisidore Nov 8, 2026
073d4e8
Name the kit cursor's per-page read refresh, and test its retry and t…
ndisidore Nov 8, 2026
e82b10e
Check a PagerDuty override before removing it; fail closed on unknown…
ndisidore Nov 8, 2026
e5efc98
Match the PagerDuty app registration steps to its form
ndisidore Nov 9, 2026
2cb2910
Match the PagerDuty deploy-wizard steps to its form, and fix its cons…
ndisidore Nov 9, 2026
b4dbe37
Send a PagerDuty snooze once; report every team; simulate status chan…
ndisidore Nov 9, 2026
cd3f50b
Page who is on call in PagerDuty
ndisidore Nov 9, 2026
08621ff
Keep PagerDuty's hooks in the kit's hook registry
ndisidore Nov 9, 2026
78ce26c
Let a kit cursor's overlay split a provider row
ndisidore Nov 9, 2026
c502fa1
Simulate and scope PagerDuty's on-call entries as they are served
ndisidore Nov 9, 2026
edeaf8e
Note the on-call listing a later PagerDuty override doesn't show
ndisidore Nov 9, 2026
6e27e8e
Let a kit cursor authorize injected rows, and harden its overlay
ndisidore Nov 9, 2026
74a486c
Authorize PagerDuty's injected incidents with the page that serves them
ndisidore Nov 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ Each gatekeeper package contains instructions for how to set it up:
* [Slack API](packages/gatekeeper-slack/README.md)
* [Spotify](packages/gatekeeper-spotify/README.md)
* [Microsoft 365](packages/gatekeeper-microsoft/README.md)
* [PagerDuty API](packages/gatekeeper-pagerduty/README.md)
* [X API](packages/gatekeeper-x/README.md)
* [ZoomInfo API](packages/gatekeeper-zoominfo/README.md)

Expand Down
1 change: 1 addition & 0 deletions docs/observers.md
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,7 @@ its resource types.
| **microsoft** | Outlook Mailbox / Folder / Conversation / Sender | **C**, kernel-checked (§10) | Every read, hook deliveries included, reports the mailbox (`outlook/user/<userId>`), folder (`…/folder/<folderId>`) or messages (`…/message/<messageId>`) it revealed, by the mailbox owner's object ID and immutable IDs, naming the tenant as for SharePoint (`outlook/tenant/<tenantId>/user/…`). `verifyObserverAccess` allows an observer only in that tenant and only if a probe `GET` as them opens each: the whole mailbox needs access to all of it (someone shared one folder gets 404), a folder or message needs that folder or message. Answers are reused as for SharePoint. `addObserver` still checks the units recorded before reads reported subresources. |
| **microsoft** | Outlook Calendar / Availability | **C**, kernel-checked (§10) | A calendar binding's reads report its calendar at the projection they showed, `calendar/user/<ownerId>/<freeBusy\|limitedCalendar\|calendar\|privateCalendar>/<calendarId>`; an every-calendar binding's reads report `calendar/user/<userId>`; free/busy reads report `calendar/availability/<address>`; each names the tenant as for SharePoint. `verifyObserverAccess` allows an observer only in that tenant: to a calendar projection only when the owner's `calendarPermissions` give them a role that sees it (`calendar-acl.ts`, where a group entry can only lower the organization default), which only the owner can read, so someone else's calendar admits that owner alone (the owner's `UserAccount` reuses a sharing list for 2 minutes); to someone's free/busy only when `getSchedule` as the observer answers (reused as for SharePoint); to a user's whole calendars nobody but that user. `addObserver` still checks the units recorded before reads reported subresources. |
| **x** | Profile | As Account, kernel-checked (§10) | As Account: a public profile reports `[]`, and a protected user's posts report `user/<id>`. Accepted over-permissiveness for every X binding: an author who has blocked the observer is still readable through the gadget, since checking each author per observer per read would cost a read each and what leaks is public. |
| **pagerduty** | Account / Team / Service / Incident / Schedule | **C**, kernel-checked (§10) | Every read, each page, an empty one or a cache hit included, reports IDs qualified by region and account (`region/<us\|eu>/account/<accountId>[/<kind>/<id>]`): the account; the bound team, service, incident or schedule; each service, schedule or escalation policy a filter named; and what the answer depends on: each distinct service on an incident page, each service, team or schedule a listing returns (a team's own service and schedule listings included, since PagerDuty opening a team needn't mean it shows what the team owns), an incident's service (and, in a Team binding, the team), and on-call listings' escalation policies and schedules, the service's or team's included even when empty. A team's member listing is covered by the team. A hook's event reports the subscribing capability's scope plus the incident and its service, after the incident is re-read as the hook's owner (the account-wide webhook carries incidents they may not see, which are dropped). `verifyObserverAccess` allows the account only to an observer whose own connection is to the same account in the same region, with a base role that sees the account's objects (not Limited Stakeholder or Restricted Access, read afresh); each object only if PagerDuty opens it for them (`GET` on it, 403/404 deny, anything else fails the check; a token PagerDuty refuses is refreshed, or the observer asked to reconnect, and the check fails). The observer's `UserAccount` reuses an allowed answer for up to 15 minutes and a denial for up to 15 seconds, partitioned by its credentials. New gatekeeper: `addObserver`/`removeObserver` are no-ops and `getHistoricalSubresources` returns `[]`. |
| **mcp-portal** | Upstream server behind the MCP Server Portal (whole server or selected tools) | **A**, kernel-checked (§10) | Admit only an observer whose verifier stands for the binding's own `McpAccount` (`props.accountObjectId`): every read through the binding was made with that account's credentials, so its holder, such as a collaborator who added the connection, sees nothing new. Refuse everyone else, another account of the same person or portal user included; no portal user identity exists to compare (`none`/`token` auth share one credential, OAuth access tokens are opaque). Every read — catalog, lookup, search, read-only call, approved-call result — reports `server/<upstreamServerId>`, the grant's `scope.serverId` (not the portal's own `serverId`), with no endpoint, account or tool in it: requirements are recorded per connection, and each connection's facet is pinned to one endpoint and account. `verifyObserverAccess` applies the same account rule to it and fails the whole call for any other ID. |
| **slack** | Workspace | **C**, kernel-checked (§10) | Every read reports what it disclosed: `workspace/<teamId>` for workspace metadata, the member directory, and a listing or search page naming no conversation; `workspace/<teamId>/conversation/<conversationId>` for each conversation a listing or search page named, and for everything read in a conversation, its threads, messages and members included, since Slack's access control stops at the conversation. `verifyObserverAccess` allows a workspace only to an observer whose own account belongs to it, and a conversation only to one whose account also opens it there (`conversations.info`), answered by their `UserAccount`, which reuses an allowed answer for up to 15 minutes and a denial for up to 15 seconds, until its credentials are next installed. Only Slack answering that the workspace or conversation is out of the observer's reach counts as no access; any other refusal, such as an expired token, a missing scope or a rate limit, fails the check. `addObserver` keeps requiring workspace membership and every conversation reads recorded before they reported subresources, pending or observed. `getHistoricalSubresources` exports those recorded requirements only with a stored workspace and a nonempty legacy tracker; it throws when history is unprovable, without consulting the current account or changing storage. |
| **slack** | Conversation / Thread | **B**, kernel-checked (§10) | Every read, including a thread binding's, reports the bound conversation as `workspace/<teamId>/conversation/<conversationId>`, checked as for a workspace binding. `addObserver` keeps checking the observer can open it, for reads made before they reported it. `getHistoricalSubresources` throws for legacy bindings because they did not record whether data was observed; bindings created since reporting began export `[]`. |
Expand Down
32 changes: 24 additions & 8 deletions packages/gatekeeper-kit/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1054,8 +1054,8 @@ Every outgoing page names all requirements for that disclosure, even if earlier
Do not drop a membership or parent-resource requirement just because `projects.length === 0`.

A refusal holds the outgoing page, so the retry re-offers its rows with no further provider
fetch: a page the provider capped short cannot grow between the refusal and the retry, which would
hand the approver something larger than what they refused. An empty page from a spent window is the
fetch: a page the provider capped short takes in no more of the provider's rows between the
refusal and the retry, which would hand the approver more than what they refused. An empty page from a spent window is the
exception — nothing was disclosed, so the retry opens a fresh window rather than pinning the walk
on a failure that may have been transient. A refused zero-result answer is likewise re-offered.
`ArrayCursor` takes no callback: the session that assembled its items authorized them as one read.
Expand All @@ -1079,22 +1079,38 @@ return new TokenCursor<Project>({
});
```

### Merge simulated rows with `inject`
### Merge simulated rows with `inject`, and change provider rows with `overlay`

Rows the gatekeeper's own simulation adds to a listing — a branch a queued push will create, a
commit queued for push — go in `inject: { items, compare, revalidate?, identity? }` rather than a
wrapper cursor, so they share the walk's serialization, fetch bound and refusal hold. Each injected
row is served before the first provider row `compare` places it at-or-before, and the rest after the
last page. They are not provider rows: they never reach `authorizePage`'s `items`, and a page
made only of them is not authorized, so do not inject anything the provider supplied. Their
row is served before the first provider row `compare` places it at-or-before, and the rest after
the last page. They are not provider rows: they never reach `authorizePage`'s `items`, and a page
made only of them is not authorized. Set `authorize` when they do carry provider data, such as a
row re-read because a pending change moves it into the listing: each then reaches `authorizePage`
with its page's provider rows, and a page made only of them is authorized like any other. Their
position still says a little — an injected row served first means no fetched provider row sorts
ahead of it — so a listing whose order is itself sensitive authorizes its observation up front.
`revalidate` re-reads a row when it is served (`null` drops it), since a page may be drained long
after the cursor was built. `identity` drops a later row of either source whose identity was
already served, such as the provider's copy of a create that landed mid-walk; an injected row's
identity is re-read at each check, so a temporary id that became the provider's still matches.
A refused page's retry re-reads its injected rows and offers no provider row the refusal did not
cover; one a dropped injected row had hidden leads the next page instead, with the rows after it.

The cursor's own `overlay` does for provider rows what `revalidate` does for injected ones. They
sit in the buffer as fetched until served, so apply the simulation's changes to them there rather
than in the fetch callback, and a change staged or discarded mid-walk shows in rows served later.
It returns the rows to serve in a row's place, so it can drop a row or split it, as a pending
override splits the on-call shift it covers. A row's pieces share a page, which can leave one
short or, for a row that splits into more than `pageSize`, long.
Both run synchronously, so state they read that needs a provider call or storage, such as whether a
row's container is still within the binding, is re-read in `refresh`, which every `next()`
awaits before building its page; refreshing it in the fetch callback would leave buffered rows
judged by whatever the last fetch saw.

A refused page's retry re-reads its rows, so `overlay` may make more or fewer of them, and offers
no provider row the refusal did not cover; one a dropped injected row had hidden, one `overlay`
had dropped, or one the page no longer has room for leads the next page instead, with the rows
after it.

### Existing-code migration only: refusal and failure

Expand Down
Loading
Loading