fix(outlook-addon): request ReadWriteItem, not ReadWriteMailbox - #259
fix(outlook-addon): request ReadWriteItem, not ReadWriteMailbox#259dobby-coder[bot] wants to merge 4 commits into
Conversation
ReadWriteMailbox is the highest permission tier, and an add-in that asks
for it can only be installed by an administrator: an individual user
cannot sideload it, which undercuts making the store the primary channel
for individual users. Certification policy §1100.5 ("may not request
unreasonably high permissions") is also a discretionary clause a reviewer
can invoke.
The add-in does not need that tier. Microsoft annotates every Office.js
member in @types/office-js with its minimum permission level, which is
the method the add-in permissions doc prescribes for sizing the request.
Across the members reachable from src/ the maximum is read/write item,
from six compose writes: Body.setAsync, removeAttachmentAsync,
addFileAttachmentFromBase64Async, saveAsync, InternetHeaders.setAsync and
InternetHeaders.removeAsync. Everything else the code reaches is read
item or restricted.
The complementary check is empty. The members that do require the mailbox
tier -- makeEwsRequestAsync, getSelectedItemsAsync, loadItemByIdAsync,
masterCategories, sendAsync -- have no hits in src/, and neither do
getCallbackTokenAsync, restUrl, ewsUrl or getSharedPropertiesAsync. The
read flow avoids EWS deliberately (read-view.ts). The Smart Alerts
LaunchEvent path does not force the wider tier either, and the banned
legacy ItemSend is unused.
test/manifest-permission.test.ts pins both halves, since the constant on
its own would only restate itself: the tier the manifest declares, and
that no member needing the wider tier is reachable from src/. It derives
the mailbox-tier member set from the type annotations rather than a
hand-written list, using only names annotated at that tier and no lower
one -- subject, getAsync and removeAsync exist at several tiers at once,
so matching those by name would flag every well-behaved call. Verified by
mutation in both directions: reverting the manifest fails the first
assertion, planting a makeEwsRequestAsync call in src/ fails the second.
Per #240 a changed permission tier freezes existing installations until
an administrator re-consents, so this is much cheaper before the first
AppSource submission than after.
Refs #254
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
getReadAttachmentContent was documented as reading "by REST or makeEwsRequest fallback". It has neither path: it calls getAttachmentContentAsync, which exists in read mode too. The stale comment matters more than usual now, because "the read flow avoids EWS deliberately" is the justification for requesting ReadWriteItem rather than ReadWriteMailbox, and the next person auditing that claim reads this comment first. Refs #254 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
CI note, not caused by this diff: the Every other check passed, including all the outlook-addon contexts ( It needs a re-run once the CDN recovers. I cannot trigger one: the app token has no |
There was a problem hiding this comment.
VERDICT: request-changes
Rules Dobby 2, cycle 1. The permission narrowing itself is sound and I verified it independently — the guard's own broad scan finds zero mailbox-tier members across all 29 files of src/, which is the conservative direction, and ReadWriteItem genuinely covers the six compose writes. The manifest change, the corrected office-helpers.ts comment, the changeset and the CLAUDE.md note are all right, and pinning the tier with a test rather than a bare constant is the right call.
The findings are all in the new guard, and one of them is blocking: the test currently passes on a tree where a real makeEwsRequestAsync call has been added to src/. Details and a verified one-click fix inline.
| # | severity | where |
|---|---|---|
| 1 | bug | manifest-permission.test.ts:89 — the >= 5 floor is padded by two names that gate nothing, so the scan can go silently blind |
| 2 | nit | manifest-permission.test.ts:104 — \b<name>\b fires on ordinary locals (and the obvious fix is worse — measured) |
| 3 | nit | the five-name claim, in the test header, the PR body and the changeset |
Findings 1 and 3 are the same root cause: the derived set is seven names, not the five every comment says, and the two extras are exactly what makes the floor unable to fail.
Nothing here is a reason to widen the tier again — the fix is in the guard, not the manifest.
CI is green apart from E2E Tests, which has been in_progress for ~45 minutes on a run whose other 25 jobs finished; worth a glance when the branch is next pushed, but it is not what is blocking this. Left in draft, as requested changes.
…them The permission guard's `mailboxOnly.length >= 5` floor could not notice the derivation losing the members it exists to protect. The derived set is seven names, not five: `hasAttachment` and `itemMode` are annotated read/write mailbox on `SelectedItemDetails`, which is only obtainable via `getSelectedItemsAsync` — already in the set — so they add no reach and only pad the count. The floor could therefore lose both `makeEwsRequestAsync` and `sendAsync` and still report `>= 5`, leaving the src/ scan silently blind. That was reachable, not theoretical: the declaration-name regex already fails to resolve 52 of the 593 annotations in @types/office-js@1.0.601, so a declaration moving out of its reach is ordinary. With the `makeEwsRequestAsync` declaration reshuffled the way an overload split does and a real `item.makeEwsRequestAsync(...)` call planted in src/lib/mime.ts, the suite reported 41/41 pass. Two changes: - `membersByTier` now tracks the declaring interface and the declaration line per member, and the candidate set drops names whose every declaring interface is handed out by another candidate. That removes `hasAttachment` and `itemMode`, which also fixes a false positive: `\b<name>\b` fired on an ordinary local (`const hasAttachment = parts.length > 0`). Narrowing the set rather than the regex keeps the broad match, which catches `item["makeEwsRequestAsync"]` and destructuring that member-access matching misses. - The count floor becomes a per-gate assertion over the five surviving names, so a derivation that drops one fails with which gate went missing. Verified against @types/office-js@1.0.601: red when either the `makeEwsRequestAsync` or the `sendAsync` declaration is reshuffled out of the parser's reach, red on a genuine `makeEwsRequestAsync` call in src/, green on the ordinary-local case that used to false-positive, and 41/41 on untouched types. The changeset and the CLAUDE.md note keep the five-name list, which the code now derives exactly; both gain a word on what "reachable" excludes. Refs #254
…egex them `stripComments` ran a whole-file `/\/\*[\s\S]*?\*\//g` over each src/ file before the scan. A `/*` that opens inside a `//` line comment has no closer of its own, so that regex pairs it with the next `*/` in the file and deletes every line between — including any call the guard exists to find. Measured on this branch, with a realistic arrangement in src/lib/mime.ts (a `// ... the extracted ./*.ts modules ...` line at the top, a real `item.makeEwsRequestAsync(...)` call below it, and an ordinary JSDoc block after that): 41/41 pass, the call unseen. src/ carries no such opener today, so this was latent rather than live — but the guard is new here and this is the same failure it was just hardened against, so it is cheaper to close now than to discover later. Stripping block comments per line, the usual remedy, trades the hole for the opposite one: the continuation lines of a multi-line block comment survive, so a JSDoc paragraph explaining why we avoid `makeEwsRequestAsync` gets reported as a call to it — and `office-helpers.ts` was given exactly that kind of comment two commits ago. So walk the source left to right instead. `//` is consumed to end of line before any `/*` on that line is looked at, and a `/*` is followed to its `*/` however many lines away that is. The `://` exemption is kept so a URL in a string does not swallow its line. A `/*` inside a string literal would still open a comment; src/ has none, and a tokenizer is more than a name scan needs. Verified in nine directions against @types/office-js@1.0.601: green on untouched src/; red on a plain call, on `item["makeEwsRequestAsync"]`, on destructuring, and on the trap arrangement above; green on the `hasAttachment` / `itemMode` locals and on a gate named in a `//` comment only; red naming the gate when either the `makeEwsRequestAsync` or the `sendAsync` declaration is moved out of the parser's reach; red when the manifest is reverted to `ReadWriteMailbox`. Refs #254
|
All three findings from cycle 1 are addressed in a5100e7, and each thread has a reply and is resolved. One thing I changed that nobody asked for, so it should not be found by surprise: 423371a replaces the guard's
Worth knowing for whoever reviews the shape: stripping block comments per line, which is the usual remedy, trades the hole for its mirror. The continuation lines of a multi-line block comment survive, so the JSDoc-style comment about avoiding Nine mutations, all against
Locally green: The manual sideload in #254 is still the one acceptance item I cannot do from here. |
There was a problem hiding this comment.
VERDICT: approve
Rules Dobby 2, cycle 2. All three cycle-1 findings are fixed and I re-verified each one against HEAD rather than taking the delta on trust. No new findings, from the review pass or the rule sweep.
The cycle-1 findings, re-checked at 423371a
| # | cycle-1 finding | state at HEAD |
|---|---|---|
| 1 (bug) | the mailboxOnly.length >= 5 floor was padded by two names that gate nothing, so the derivation could lose makeEwsRequestAsync and sendAsync and stay green |
fixed — the floor is gone, replaced by a per-gate assert.ok(mailboxOnly.includes(gate)) over all five names (manifest-permission.test.ts:178). A dropped gate now fails naming itself. |
| 2 (nit) | \b<name>\b fired on ordinary locals like const hasAttachment = ... |
fixed — the reachability filter (:155) drops names whose every declaring interface is handed out by another candidate, which removes exactly hasAttachment and itemMode. The broad match is kept, so item["makeEwsRequestAsync"] and destructuring are still caught. |
| 3 (nit) | the five-name claim did not match the seven names the code derived | fixed — the test header, CLAUDE.md, the changeset and the PR body all now name the same five, and that is what the code derives. |
What I checked independently
- The tier argument itself. A broad grep of
src/for the five mailbox-tier members plusgetCallbackTokenAsync,restUrl,ewsUrlandgetSharedPropertiesAsyncreturns exactly one hit: the new//comment inoffice-helpers.ts. Prose, not a call. So the narrowing is sound and the corrected comment is correctly not flagged. - The comment strip. The left-to-right scan is the shape that covers both failure directions — the whole-file regex pair deletes real code after a
/*opened inside a//line, and the per-line strip invents a hit off a JSDoc continuation line. The one residual gap is a/*or a bare//inside a string or regex literal, which the scan would still treat as a comment. Grepped for all three shapes acrosssrc/: none present, so it is latent and the scan is the right call over a tokenizer. - The guard's scope. The scan covers
src/only, so I enumerated the producers myself instead of trusting the description. All six webpack entry points are undersrc/; the only Office.js mentions elsewhere in the package are a comment inwebpack.config.jsand the test files.src/is the complete set for shipped add-in code. - The silent-skip risk in the parser.
membersByTierhas theif (!name) continueshape that normally makes a source-parsing guard fail open — a declaration it cannot parse is dropped with nothing said. The per-gate assertion is what converts that from a silent skip into a named failure, which is why finding 1 mattered beyond the arithmetic. - CI, on this HEAD. Green across the board, nothing still in progress. The addon suite reports
tests 41 / pass 41 / fail 0 / cancelled 0— checked forcancelled, not justfail 0— and both new assertions appear as passing by name.E2E Tests, which was stuckin_progressat cycle 1, passed in 1m10s. The new test is also covered by CI's eslint, prettier and typecheck globs, so it is gated, not just present.
Rule sweep
Selected 17 rules against the changed surfaces — source-text guard construction, source parsing, node:test output, guard scope, PR/prose hygiene, changeset and draft handling. No breaches. Notably clean on the two that most often bite this shape: the comment-strip rule and the "make optional exactly what the source makes optional" rule.
Before merge
The outstanding item is the one the PR names and I cannot do either: the tier is enforced by Outlook per API call, so green CI does not prove the compose and read flows still work. The manual sideload acceptance step in #254 stands — sideload dist/manifest.xml, confirm compose (encrypt and send, including plaintext attachment removal and the postguard.encrypted attachment) and read (decrypt) end to end. The static argument is that every member either flow calls is annotated at or below read/write item, so no call should be denied; that is an argument, not a test on a real host.
Flipping out of draft so it can be reviewed and that step scheduled. No advisory involved, so nothing here is held back.
Closes #254.
apps/outlook-addon/manifest.xmlrequestedReadWriteMailbox, the highest permission tier. It now requestsReadWriteItem.Why the narrower tier is the right one
Microsoft annotates every Office.js member in
@types/office-jswith itsMinimum permission level, and that annotation is the method the add-in permissions doc itself prescribes for sizing the request. I re-derived the audit from #242 against HEAD and@types/office-js@1.0.601rather than taking it on trust, mapping everyOffice.*anditem.*member reached fromsrc/to its annotation.The maximum across the whole set is read/write item, from six compose writes:
Body.setAsyncMessageCompose.removeAttachmentAsyncMessageCompose.addFileAttachmentFromBase64AsyncMessageCompose.saveAsyncInternetHeaders.setAsyncInternetHeaders.removeAsyncEverything else the code reaches is read item or restricted:
Subject.getAsync/setAsync,Recipients.getAsync,From.getAsync,Body.getAsync,InternetHeaders.getAsync,getAttachmentsAsync,getAttachmentContentAsync,getAllInternetHeadersAsync,addHandlerAsync,NotificationMessages.replaceAsync/removeAsync,UserProfile.emailAddress/displayName, androamingSettings(restricted).The complementary check came back empty. The mailbox-tier members an add-in can reach have no hits in
src/:makeEwsRequestAsync,getSelectedItemsAsync,loadItemByIdAsync,masterCategories,sendAsync. Neither dogetCallbackTokenAsync,restUrl,ewsUrlorgetSharedPropertiesAsync. The read flow avoids EWS deliberately, reading attachment bytes throughgetAttachmentContentAsyncin both modes (office-helpers.ts). The twoLaunchEvents do not force the wider tier either, and the banned legacyItemSendis unused.The second commit is part of the same concern rather than a drive-by:
getReadAttachmentContentwas documented as reading "by REST or makeEwsRequest fallback" and has neither path. "The read flow avoids EWS deliberately" is the justification for the narrower tier, and that comment is the first thing anyone auditing the claim reads.The test
test/manifest-permission.test.tspins both halves, because the declared constant on its own would only restate itself:ReadWriteItem.src/.The second is what keeps the first honest. It derives the mailbox-tier member set from the annotations in
@types/office-jsinstead of a hand-written list, so a types bump that moves a member between tiers is picked up here. Two filters turn 593 annotations into names whose bare presence insrc/is proof of a reach for the wider tier:subject,getAsyncandremoveAsyncall appear at read/write mailbox (onSelectedItemDetailsandMasterCategories) as well as on the ordinary item, so matching those by name would flag every well-behaved call.hasAttachmentanditemModeare annotated read/write mailbox and nowhere else, but they sit onSelectedItemDetails, which an add-in can only obtain by callinggetSelectedItemsAsync— already in the set. They add no reach.What survives is the five gates, and the floor names them one by one rather than counting the set. It strips comments before scanning, since the question is what the code reaches and not what the code talks about; the corrected comment in
office-helpers.tsnamesmakeEwsRequestAsyncin prose and is correctly not flagged. It fails loudly rather than asserting nothing if it cannot parse the annotations or thesrc/tree.Verified by mutation in both directions, not by inspection:
ReadWriteMailbox→ check 1 fails, 2 passes.makeEwsRequestAsynccall planted insrc/→ check 2 fails naming the file and the member, 1 passes.makeEwsRequestAsyncdeclaration in@types/office-jsreshuffled out of the parser's reach, so the derivation loses it → fails withmakeEwsRequestAsync is no longer derivable as mailbox-tier, so the scan is blind to it. Same forsendAsync.hasAttachment/itemModeplanted insrc/lib/mime.ts→ passes, no false claim./*opened inside a//comment above a real call, with an ordinary JSDoc block below it → fails. The whole-file block-comment regex this replaced reported 41/41 there, blind to the call.What I ran
pnpm validate(source manifest)pnpm build+pnpm validate:dist, edge originsdist/manifest.xml:43readsReadWriteItempnpm build+pnpm validate:dist, production originspnpm typecheck(app) andpnpm -r typecheckpnpm check-versionLint, typecheck & buildpnpm -r testThe two entrypoint size warnings on
taskpane.jsandyivi-dialog.jsare the documented baseline, not a regression.Still needed before merge, and I could not do it
The tier is enforced by Outlook at each API call, so a green CI run does not prove the flows still work. The manual acceptance step in #254 stands: sideload
dist/manifest.xmland confirm compose (encrypt and send, including plaintext attachment removal and thepostguard.encryptedattachment) and read (decrypt a message) end to end. I have no Outlook host or M365 account here.What I can offer instead is the static half: every member the flows call is annotated at or below read/write item, so no call in either flow should be denied. That is an argument, not a test on a real host.
Out of scope, deliberately
apps/website/static/downloads/postguard-outlook-manifest.xml:43also saysReadWriteMailbox. It is a mirror of the manifest attached to the lastoutlook-addin-v*release, written byapps/website/scripts/sync-addons.mjs, and that release genuinely shipped the wider tier. It updates itself on the next release; hand-editing it would put the mirror out of step with the artifact it mirrors. Noted inapps/outlook-addon/CLAUDE.mdso the next person does not go looking.Per #240 a changed permission tier freezes existing installations until an administrator re-consents, which is the argument for landing this before the first AppSource submission rather than after.
Review round 1 (a5100e7)
The guard's
mailboxOnly.length >= 5floor could not notice the derivation losing the members it existed to protect. The derived set was seven names, not the five every comment claimed:hasAttachmentanditemModepadded it, so the floor could lose bothmakeEwsRequestAsyncandsendAsyncand still clear>= 5. Reproduced on the previous head — with themakeEwsRequestAsyncdeclaration reshuffled the way an overload split does and a realitem.makeEwsRequestAsync(...)call insrc/lib/mime.ts, the suite reported 41/41 pass.hasAttachmentanditemModeand leaves the five gates. That also clears a false positive:\b<name>\bfired onconst hasAttachment = parts.length > 0. Narrowing the set rather than the regex keeps the broad match, which still catchesitem["makeEwsRequestAsync"]and destructuring that member-access matching would miss.423371a — the comment strip, not asked for
stripCommentsran a whole-file/\/\*[\s\S]*?\*\//gover eachsrc/file. A/*that opens inside a//line comment has no closer of its own, so that regex pairs it with the next*/in the file and deletes every line in between, call sites included. With a// ... the extracted ./*.ts modules ...line at the top ofsrc/lib/mime.ts, a realitem.makeEwsRequestAsync(...)below it and an ordinary JSDoc block after that, the guard reported 41/41 with the call unseen.src/carries no such opener today, so this was latent rather than live, and it is outside the three findings — flagging it here rather than burying it. The reason I fixed it instead of filing it: it is the same failure the blocking finding was about, in a file this PR introduces, so it would land as a hole in a brand-new guard. Say the word and I will pull it into its own PR.Stripping block comments per line, the usual remedy, trades that hole for its mirror: the continuation lines of a multi-line block comment survive the strip, so a JSDoc paragraph explaining why we avoid
makeEwsRequestAsyncgets reported as a call to it — andoffice-helpers.tswas given exactly that kind of comment in this PR's second commit. So the strip now walks the source left to right://is consumed to end of line before any/*on that line is looked at, and a/*is followed to its*/however many lines away that is. The://exemption stays, so a URL in a string does not swallow its line.Verification
Re-ran against
@types/office-js@1.0.601:pnpm test41/41,pnpm typecheckclean, the exact CI eslint and prettier globs clean,pnpm validatevalid,pnpm buildwith the production origins pluspnpm validate:distvalid (dist/manifest.xmlreadsReadWriteItem), andpnpm -r testgreen across all five workspace packages (344 + 41 + 212 + 20 + 2 files).E2E Tests, which stalled on its Playwright download in the last round, passed on this head.The manual sideload step in #254 is still outstanding and still cannot be done from here.