feat(meetings): read the user's calendar over CalDAV, Google and Microsoft 365 - #2190
feat(meetings): read the user's calendar over CalDAV, Google and Microsoft 365#2190kaizawa97 wants to merge 1 commit into
Conversation
ecd492a to
28351ac
Compare
28351ac to
7035762
Compare
Design Review (Fable 5, fork) — 🔴 BLOCK (blocking)Design-level review of All key checks are done. I have what I need for the verdict: the diff adds Design-Verdict: BLOCK The credential store's OS-sandbox half is missing: the refresh token is fenced only at the tool gate, which spawned shells never route through. BlockersThe new secret leaf has no sandbox disposition, so a spawned shell reads the live tokens in every mode. Watch
This is the product's first Kiro-Crew-custodied OAuth chain, while SuggestionsThe two-way function-local import cycle between [DESIGN-REVIEWED] ca08283 |
GPT 5.6 Review (fork) — 🔴 changes requested (blocking)Reviewed 2 of 2 blocking finding(s) are security-class and were withheld from adjudication, so the blocking verdict stands. BLOCKING -- src/kiro_crew/security/paths.py:296 -- Calendar credentials remain readable to agent subprocesses BLOCKING -- src/kiro_crew/apps/builtins/meetings/backend/providers/calendar.py:1565 -- Cloud providers discard all-day semantics FINDING -- src/kiro_crew/apps/builtins/meetings/backend/providers/calendar.py:10 -- FINDING -- src/kiro_crew/apps/builtins/meetings/backend/credentials.py:183 -- [BLOCK-MERGE] ca08283 Adjudication (Opus 4.8) — is blocking on each finding proportionate?Both fenced findings verified. F1: the diff adds F2: F1 conditions: paths.py:199 ( |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings that block; one advisory. FINDING — src/kiro_crew/apps/builtins/meetings/backend/oauth.py:743 — [OPUS-REVIEWED] ca08283 |
7035762 to
5d22109
Compare
|
🤖 Kiro Crew [operator: bolichen97]: This PR has been inactive for 7+ days with failing CI. I've assessed the blockers and they appear resolvable — I'll push fixes directly to this branch as a co-author. Assessment: GPT 5.6 flagged 3 concrete bugs (credential temp-file path gate bypass, concurrent-write race in credentials store, read-failure causing credential loss). The only CI test failure ( If you'd prefer I don't touch this PR, add the |
5d22109 to
269e4a1
Compare
|
🤖 Kiro Crew [operator: bolichen97] Drive-to-green pass on this PR (original work by @kaizawa97, authorship preserved; head is now 1. Rebased onto current main (was based on a commit from 08-13). This also absorbs the fix for the 2. Re: the GPT 5.6 blocking review of 3. One new fix (local GPT 5.6 review of the rebased head): the invalid/expired/forged- 4. PR description updated to remove the stale claim that Local gates on the pushed head: isort / flake8 / mypy clean, full pytest 56380 passed. Opus 4.8 local review: NO-BLOCKING (SSRF vetting, redirect auth-drop, gate coverage, and Graph timezone handling all verified sound). |
|
🤖 Kiro Crew [operator: bolichen97] — dispositions for the GPT 5.6 blocking review of
|
|
🤖 Kiro Crew [operator: bolichen97] Push 9 ( |
) (kirodotdev#4380) A VALUE=DATE event parses to midnight UTC, so a browser west of UTC rendered it on the previous day (a Los Angeles user saw yesterday 17:00 for today's all-day event). The module's convention forbids dropping date-only values, so the fix keeps the midnight-UTC value as a date anchor and adds an all_day flag to CalendarEvent: the ICS parser classifies by the DTSTART body's shape (exactly eight digits), so a date body whose VALUE parameter is missing, vendor-prefixed, or mislabeled is kept and flagged rather than dropped, and the meetings list renders flagged rows as the calendar date alone, with the date fields read back in UTC and no time shown. GET /calendar normalizes a missing all_day key to false so a cache written before the field existed still satisfies the frontend's required wire type; a legacy all-day row renders as timed until the next sync (all-day-ness is not recoverable from midnight alone). An all-day event with no DTEND/DURATION now spans its whole calendar date per RFC 5545 §3.6.1 instead of the nominal one-hour default. The flag is schema-level so every provider parsing a date-without-time value (the CalDAV/Google/M365 paths in flight on PR kirodotdev#2190) sets it the same way instead of each provider inventing its own treatment. Closes kirodotdev#4328
`calendar.source` is fetched BY THE GATEWAY, so the address check on it is a
server-side request-forgery gate. That check was a local copy in the meetings
calendar provider, and it judged addresses with `ipaddress.is_private`, which
does not cover two ranges that are plainly not public:
>>> ipaddress.ip_address("100.64.0.1").is_private
False
>>> ipaddress.ip_address("fec0::1").is_private
False
`100.64.0.0/10` is RFC 6598 shared space -- what a tailnet and most carrier NAT
hand out, so on a machine on a tailnet that range IS the private network.
`fec0::/10` is deprecated IPv6 site-local. Both were approved, resolved, and
fetched. `.local` and `.onion` were approved too: the local copy had no
host-suffix rule.
Verified by running the pre-change code, not by reading it: the new regression
cases fail on the parent commit with DID NOT RAISE.
The fix is to stop keeping a second copy. `link_unfurl.vet_unfurl_url` already
owns this decision for the unfurl endpoint, uses `is_global` as an allowlist
alongside the category flags, refuses the blocked host suffixes, canonicalizes
the alternate IPv4 encodings, and carries
`test_vet_rejects_every_special_purpose_range` -- which pins the refusal set
against a table of IANA special-purpose prefixes, so the next gap is found by the
suite instead of by a reviewer. A second implementation inherits none of that,
and is a second place to fix a bypass.
`resolve` is injected for one reason: `VettedUrl` reports a single `ip`, while
the pin serves every vetted address so a multi-homed calendar host keeps its
fallbacks. Every address recorded is one the vet checked -- it vets the whole
answer, not just the address it keeps. The pin, the redirect hop loop and the
TLS-hostname behavior are unchanged; only the address decision moves.
Two behavior changes worth calling out:
* Ports narrow to 443. The shared vet allows {80, 443} because it also serves
plain-http unfurling; https-only leaves 443. A calendar on another port is
nearly always an internal service. `ics` only ever documented a published
`https://` URL, so no working configuration breaks; relaxing later is one line.
* The operator-facing rejection messages change wording, since the two
`UnfurlRejected` codes are now what gets mapped.
The alternate IPv4 encodings (`0177.0.0.1`, `0x7f000001`, `2130706433`, `127.1`)
were NOT reachable before -- getaddrinfo folded them to loopback and the private
rule caught them there. They are pinned anyway, in a separately named test,
because that refusal depended on the resolver's reading of a string the vet had
declined to parse: agreement rather than a decision.
Delegating also required closing a gap in the shared vet, because the local copy
was stronger in one respect: it judged an embedded IPv4 address in BOTH v6
encodings, and `link_unfurl` handled only `ipv4_mapped`. `2002:0a00:0001::1` is
routed to `10.0.0.1`, and `2002::/16` entered CPython's IPv6 private table only
with gh-113171 (3.10.14, 3.11.9, 3.12.4) -- so on an older patch release of a
supported version the v6 form reports `is_global` while the packet goes inward.
Fixed in `link_unfurl` rather than by layering a local check back around the
delegation: the unfurl endpoint has the same gap, and one owner of the decision
is the point of this change.
That check is an AND, not a substitution -- the encodings are not symmetric.
`::ffff:1.2.3.4` has no meaning as a v6 destination, so the mapped address is the
only thing to judge (unchanged). `2002:xxxx:yyyy::/48` is a routable v6 prefix
AND names a v4 tunnel endpoint, so both readings must pass. Substituting was the
first attempt and `test_vet_rejects_every_special_purpose_range` caught it:
`2002:8000::` carries the PUBLIC `128.0.0.0`, so substitution made a 6to4 address
pass that the v6 `is_private` had refused. The flag union moves into
`_is_not_public` so one formulation covers both readings.
Its test does not trust the interpreter to disagree: it drops `2002::/16` from
`ipaddress._IPv6Constants._private_networks` for its duration to reproduce the
older patch releases, asserts the premise, then asserts the refusal holds.
Extracted from #2190, which needs this fix plus three new providers, a credential
store and an OAuth handshake. This half stands alone and fixes code that is
already shipped, so it should not wait on the rest.
Co-authored-by: Kai Mitsuzawa <kaizawa97@users.noreply.github.com>
`calendar.source` is fetched BY THE GATEWAY, so the address check on it is a
server-side request-forgery gate. That check was a local copy in the meetings
calendar provider, and it judged addresses with `ipaddress.is_private`, which
does not cover two ranges that are plainly not public:
>>> ipaddress.ip_address("100.64.0.1").is_private
False
>>> ipaddress.ip_address("fec0::1").is_private
False
`100.64.0.0/10` is RFC 6598 shared space -- what a tailnet and most carrier NAT
hand out, so on a machine on a tailnet that range IS the private network.
`fec0::/10` is deprecated IPv6 site-local. Both were approved, resolved, and
fetched. `.local` and `.onion` were approved too: the local copy had no
host-suffix rule.
Verified by running the pre-change code, not by reading it: the new regression
cases fail on the parent commit with DID NOT RAISE.
The fix is to stop keeping a second copy. `link_unfurl.vet_unfurl_url` already
owns this decision for the unfurl endpoint, uses `is_global` as an allowlist
alongside the category flags, refuses the blocked host suffixes, canonicalizes
the alternate IPv4 encodings, and carries
`test_vet_rejects_every_special_purpose_range` -- which pins the refusal set
against a table of IANA special-purpose prefixes, so the next gap is found by the
suite instead of by a reviewer. A second implementation inherits none of that,
and is a second place to fix a bypass.
`resolve` is injected for one reason: `VettedUrl` reports a single `ip`, while
the pin serves every vetted address so a multi-homed calendar host keeps its
fallbacks. Every address recorded is one the vet checked -- it vets the whole
answer, not just the address it keeps. The pin, the redirect hop loop and the
TLS-hostname behavior are unchanged; only the address decision moves.
Two behavior changes worth calling out:
* Ports narrow to 443. The shared vet allows {80, 443} because it also serves
plain-http unfurling; https-only leaves 443. A calendar on another port is
nearly always an internal service. `ics` only ever documented a published
`https://` URL, so no working configuration breaks; relaxing later is one line.
* The operator-facing rejection messages change wording, since the two
`UnfurlRejected` codes are now what gets mapped.
The alternate IPv4 encodings (`0177.0.0.1`, `0x7f000001`, `2130706433`, `127.1`)
were NOT reachable before -- getaddrinfo folded them to loopback and the private
rule caught them there. They are pinned anyway, in a separately named test,
because that refusal depended on the resolver's reading of a string the vet had
declined to parse: agreement rather than a decision.
Delegating meant the shared vet had to absorb two things the local copy did, and
both fixes land in `link_unfurl` rather than as local checks layered back around
the delegation -- the unfurl endpoint reaches both today, and one owner of the
decision is the point of this change.
1. **6to4.** The local vet judged an embedded IPv4 address in BOTH v6 encodings;
`link_unfurl` handled only `ipv4_mapped`. `2002:0a00:0001::1` is routed to
`10.0.0.1`, and `2002::/16` entered CPython's IPv6 private table only with
gh-113171 (3.10.14, 3.11.9, 3.12.4), so on an older patch release of a
supported version the v6 form reports `is_global` while the packet goes inward.
The check is an AND, not a substitution -- the encodings are not symmetric.
`::ffff:1.2.3.4` has no meaning as a v6 destination, so the mapped address is
the only thing to judge (unchanged). `2002:xxxx:yyyy::/48` is a routable v6
prefix AND names a v4 tunnel endpoint, so both readings must pass. Substituting
was the first attempt and `test_vet_rejects_every_special_purpose_range` caught
it: `2002:8000::` carries the PUBLIC `128.0.0.0`. The flag union moves into
`_is_not_public` so one formulation covers both readings.
Its test does not trust the interpreter to disagree: it drops `2002::/16` from
`ipaddress._IPv6Constants._private_networks` for its duration to reproduce the
older patch releases, asserts the premise, then asserts the refusal holds.
2. **A host the resolver cannot encode.** `getaddrinfo` and yarl both raise
`UnicodeError` -- a `ValueError`, NOT an `OSError` -- for a host carrying a lone
surrogate, which arrives intact from a JSON string. The vet's fail-closed catch
around the resolver was `OSError`-only and the `wire_host` derivation had no
guard, so `https://\ud800.example/` escaped as an uncaught exception and a 500.
Verified against unmodified origin/main: the unfurl endpoint leaks it there
today. The calendar provider only surfaces it because delegating removed the
`URL(url)` parse that used to reject the surrogate by accident -- so this is
both a live bug in one caller and a regression guard for the other, and it is
pinned in both suites.
Extracted from #2190, which needs this fix plus three new providers, a credential
store and an OAuth handshake. This half stands alone and fixes code that is
already shipped, so it should not wait on the rest.
Co-authored-by: Kai Mitsuzawa <kaizawa97@users.noreply.github.com>
#5217) `calendar.source` is fetched BY THE GATEWAY, so the address check on it is a server-side request-forgery gate. That check was a local copy in the meetings calendar provider, and it judged addresses with `ipaddress.is_private`, which does not cover two ranges that are plainly not public: >>> ipaddress.ip_address("100.64.0.1").is_private False >>> ipaddress.ip_address("fec0::1").is_private False `100.64.0.0/10` is RFC 6598 shared space -- what a tailnet and most carrier NAT hand out, so on a machine on a tailnet that range IS the private network. `fec0::/10` is deprecated IPv6 site-local. Both were approved, resolved, and fetched. `.local` and `.onion` were approved too: the local copy had no host-suffix rule. Verified by running the pre-change code, not by reading it: the new regression cases fail on the parent commit with DID NOT RAISE. The fix is to stop keeping a second copy. `link_unfurl.vet_unfurl_url` already owns this decision for the unfurl endpoint, uses `is_global` as an allowlist alongside the category flags, refuses the blocked host suffixes, canonicalizes the alternate IPv4 encodings, and carries `test_vet_rejects_every_special_purpose_range` -- which pins the refusal set against a table of IANA special-purpose prefixes, so the next gap is found by the suite instead of by a reviewer. A second implementation inherits none of that, and is a second place to fix a bypass. `resolve` is injected for one reason: `VettedUrl` reports a single `ip`, while the pin serves every vetted address so a multi-homed calendar host keeps its fallbacks. Every address recorded is one the vet checked -- it vets the whole answer, not just the address it keeps. The pin, the redirect hop loop and the TLS-hostname behavior are unchanged; only the address decision moves. Two behavior changes worth calling out: * Ports narrow to 443. The shared vet allows {80, 443} because it also serves plain-http unfurling; https-only leaves 443. A calendar on another port is nearly always an internal service. `ics` only ever documented a published `https://` URL, so no working configuration breaks; relaxing later is one line. * The operator-facing rejection messages change wording, since the two `UnfurlRejected` codes are now what gets mapped. The alternate IPv4 encodings (`0177.0.0.1`, `0x7f000001`, `2130706433`, `127.1`) were NOT reachable before -- getaddrinfo folded them to loopback and the private rule caught them there. They are pinned anyway, in a separately named test, because that refusal depended on the resolver's reading of a string the vet had declined to parse: agreement rather than a decision. Delegating meant the shared vet had to absorb two things the local copy did, and both fixes land in `link_unfurl` rather than as local checks layered back around the delegation -- the unfurl endpoint reaches both today, and one owner of the decision is the point of this change. 1. **6to4.** The local vet judged an embedded IPv4 address in BOTH v6 encodings; `link_unfurl` handled only `ipv4_mapped`. `2002:0a00:0001::1` is routed to `10.0.0.1`, and `2002::/16` entered CPython's IPv6 private table only with gh-113171 (3.10.14, 3.11.9, 3.12.4), so on an older patch release of a supported version the v6 form reports `is_global` while the packet goes inward. The check is an AND, not a substitution -- the encodings are not symmetric. `::ffff:1.2.3.4` has no meaning as a v6 destination, so the mapped address is the only thing to judge (unchanged). `2002:xxxx:yyyy::/48` is a routable v6 prefix AND names a v4 tunnel endpoint, so both readings must pass. Substituting was the first attempt and `test_vet_rejects_every_special_purpose_range` caught it: `2002:8000::` carries the PUBLIC `128.0.0.0`. The flag union moves into `_is_not_public` so one formulation covers both readings. Its test does not trust the interpreter to disagree: it drops `2002::/16` from `ipaddress._IPv6Constants._private_networks` for its duration to reproduce the older patch releases, asserts the premise, then asserts the refusal holds. 2. **A host the resolver cannot encode.** `getaddrinfo` and yarl both raise `UnicodeError` -- a `ValueError`, NOT an `OSError` -- for a host carrying a lone surrogate, which arrives intact from a JSON string. The vet's fail-closed catch around the resolver was `OSError`-only and the `wire_host` derivation had no guard, so `https://\ud800.example/` escaped as an uncaught exception and a 500. Verified against unmodified origin/main: the unfurl endpoint leaks it there today. The calendar provider only surfaces it because delegating removed the `URL(url)` parse that used to reject the surrogate by accident -- so this is both a live bug in one caller and a regression guard for the other, and it is pinned in both suites. Extracted from #2190, which needs this fix plus three new providers, a credential store and an OAuth handshake. This half stands alone and fixes code that is already shipped, so it should not wait on the rest. Co-authored-by: Zejiang Guo <zejiangg@amazon.com> Co-authored-by: Kai Mitsuzawa <kaizawa97@users.noreply.github.com>
|
🤖 Kiro Crew [operator: iamwhatever]: adopting this PR for drive-to-green (prior operator claim is 12 days stale). Plan: rebase onto current main to clear the merge conflict (the sole change since the zero-diff retrigger |
4d8c8a4 to
11cd535
Compare
|
🤖 Kiro Crew [operator: iamwhatever] Push 10 (
Local gates all green: 1622 meetings+security tests, isort, flake8, mypy (1209 files), baselined black gate. Single commit, author @kaizawa97 preserved, Co-authored-by: Kiro Crew trailer. The GPT round-6 disposition ledger stands; finding is now fixed rather than deferred. |
11cd535 to
0b463a1
Compare
|
🤖 Kiro Crew [operator: iamwhatever] — disposition for the GPT 5.6 review of
|
|
🤖 Kiro Crew [operator: iamwhatever] — disposition for the First Principles BLOCK on
|
|
🤖 Kiro Crew [operator: iamwhatever] — status on head All remaining CI reds are main-side breakage inherited via the CI merge-ref, not this PR's code:
This PR's own surface is green: all 1622 meetings+security tests, isort/flake8/mypy/baselined-black pass locally on this head. Review lanes gate on |
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
…osoft 365
Meetings had to be created by hand, so the app never knew what was already
scheduled. This adds calendar providers: CalDAV, Google Calendar and
Microsoft 365, behind one interface, plus the credential store and OAuth
handshake they need.
Security decisions worth naming, because they shaped the code:
Credentials live under `<crew-home>/workspace/meetings/`, not
`app_data_dir("meetings")`. The latter is a region `store.contain` opens to
agent-supplied paths, and a calendar refresh token is not something to keep
where an agent can ask for a file.
The address vet is `link_unfurl.vet_unfurl_url`, reused rather than
reimplemented. This started as a purpose-built check in `calendar.py` and that
was a mistake: a second copy of this logic is a second place to fix a bypass,
and the copy was already weaker in four ways the shared one covers.
0177.0.0.1 approved, then fetched at 177.0.0.1 — `ipaddress` will
not read the octal, so it fell through to DNS and
getaddrinfo read `0177` as decimal. The vet and the
connection disagreed about the target, which is the exact
class of bug the pinning exists to prevent.
100.64.0.0/10 approved. `is_private` does not cover CGNAT; only
`is_global` does. On a machine on a tailnet, that range
IS the private network.
fec0::/10 approved. Deprecated IPv6 site-local reports is_global.
.local / .onion approved. Resolve through a side channel, or not at all.
`link_unfurl` also owns `test_vet_rejects_every_special_purpose_range`, which
pins the refusal set against a table of IANA special-purpose prefixes — so the
next gap is found by the suite instead of by a reviewer. A second
implementation here would not have inherited that.
One refusal went the other way and is kept, layered on top: a resolved address
that `ipaddress` cannot read is refused, not skipped.
`_reject_if_internal_ip` returns silently for a non-literal, because to it a
non-literal is a hostname still to be resolved; here the list is already a
resolution result, so an unreadable entry would reach the pin unchecked.
The pin, the redirect hop loop and the same-origin check stay local. They are
not duplicated — `VettedUrl`'s own docstring says to pin the resolver on
`wire_host`, so this is the caller side of that contract. `resolve` is injected
only to keep every address the vet approved, since `VettedUrl` reports one and
a multi-homed calendar host should keep its fallbacks.
Ports narrow to 80/443, which https-only leaves as 443. That is stricter than
"whatever port the URL names", deliberately: a calendar on another port is
nearly always an internal service, and the port is the cheapest place to stop
this endpoint being used to probe for one. Nothing is broken by starting
strict — this feature has never shipped — and relaxing it later is one line.
XML parsing rejects a DOCTYPE outright. Measured, not assumed: the stdlib
ElementTree expands internal entities while refusing external ones, and
`XMLParser.doctype` is ignored on 3.12, so the rejection is done in
`TreeBuilder.doctype` where it is actually reached. `defusedxml` would be the
obvious answer but `test_pip_deps_consistency.py` pins the extras, so adding
a dependency is a wider change than this needs.
Redaction is applied once, in `build_event()`, rather than per provider — a
new provider then cannot forget it, and `security_posture.py` gains one sink
instead of three.
The OAuth redirect URI is derived from the request's own origin. Dashboard
auth is an HMAC-signed cookie scoped to the host, and the port is
configurable, so a constant would break on any non-default port and
`localhost` vs `127.0.0.1` would not share the cookie.
Drive-to-green amendments by Kiro Crew (original work by kaizawa97):
rebased onto current main; SEL-audit the invalid/expired/forged-state
rejection in the OAuth callback so the anti-forgery refusal is visible
in the audit trail, with a test locking the record in.
Coverage Gate: added route-level tests for the credential GET/PUT/forget
surface, the OAuth start route, the callback success path, and the
credential store's failure-path contracts (atomic-write cleanup,
owner-lockdown degradation, empty-provider refusal), lifting
routes/calendar.py 56%->95% and credentials.py 77%->100% past the 80%
per-file floor without touching the baseline.
Review round 2: the credential store now writes through the repo's
canonical atomic_write helper (restrict_to_owner=True, fail-closed) —
the temp is locked to the owner BEFORE content lands and a lockdown
failure refuses the write instead of publishing tokens under an
inherited Windows ACL, closing a GPT blocking finding and deleting the
hand-rolled temp+fsync+replace copy First Principles flagged.
Review round 3: event ids are derived from the ORIGINAL provider uid,
not the redacted form. Microsoft Graph ids are long base64 blobs that
trip the credential redactor's entropy heuristic, so different events
collapsed to one [REDACTED] placeholder, one shared digest, one shared
meeting directory. Redaction still applies to every displayed field;
_event_id_for makes the raw uid filesystem-safe itself.
Review round 4: (1) schema-invalid credential stores now raise
_StoreUnreadable on every invalid root/entry/value instead of silently
dropping entries — a partial view must never be rewritten back, the
same contract the parse-failure path already had. (2) A uid the
credential redactor flags switches to a digest-only event-id source, so
a credential-shaped uid never surfaces a readable stem in the
agent-visible id (while staying unique per uid and stable across
syncs). (3) https-only now implies port 443 at the vet boundary, keyed
on the scheme allow-list so the real-server rebinding tests keep
working.
Review round 5: hoisted the oauth import in routes/calendar.py to
module scope (no circular dependency exists; oauth imports
providers.calendar, nothing imports back into routes). The symlink
finding on the credential directory is rebutted on the PR with
reachability evidence; class-level parent-symlink refusal belongs in
the shared atomic_write helper and is tracked as a follow-up issue.
Rebased onto main to absorb the new baselined black gate; ran
black --target-version py310 on the three in-scope offenders.
Rebased onto current main (1600+ commits), reconciling with the meetings
fixes that landed there since this branch's last rebase:
* kirodotdev#4380 (all-day rendering): the all_day flag is threaded through this
PR's build_event funnel so .ics all-day events keep main's date-only
semantics; providers still default to timed events.
* kirodotdev#5557 (Windows TZID -> IANA): _graph_when now consults the same
_WINDOWS_TO_IANA table before falling back to UTC, closing the
"Tokyo Standard Time read as UTC" gap for Graph responses that ignore
the Prefer header. Regression tests updated to pin the mapping and the
unknown-zone fallback separately.
* The workspace/meetings credential-store entry made the variable-leaf
gate fence the whole crew workspace (breaking main's pinned
`cat ~/.kiro/crew/workspace/$PROJ/notes.md`); the workspace parent is
now in _GENERAL_PURPOSE_PARENT_DIRS per the .config/gcloud precedent,
with the meetings child still fully fenced by is_sensitive_path.
The setup.cfg defusedxml hunk is dropped: main now declares defusedxml
in install_requires itself, so the hunk had become a duplicate
declaration with a conflicting ceiling.
Co-authored-by: Kiro Crew <noreply@kirodotdev.github.io>
0b463a1 to
ca08283
Compare
|
Rebased onto main Conflicts and how they were resolved:
Gates run locally on the changed files: Please review the resolution, especially the denylist relocation. A maintainer push makes the maintainer the last pusher, so under this repo's last-push rule a second approver is needed. Reply here if anything looks wrong. |
Problem / Motivation
Meetings had to be created by hand. The app never knew what was already scheduled,
so the whole point of a meetings assistant — being ready for the meeting that is
about to start — was left to the user to arrange manually every time.
There was no way to connect a calendar at all: no provider, no credential store,
and no OAuth handshake for the two providers that require one.
Why it matters
Every downstream feature is gated on knowing the schedule. Without it the app
cannot pre-create a meeting, cannot name it, cannot attach the right attendees, and
cannot start on time — the user has to notice the meeting themselves and set it up
by hand, which is exactly the work they wanted delegated.
It also blocks the calendar UI, which is a separate PR: there is nothing for it to
render until a provider exists.
What changed (motivation → approach → change)
Goal. Read the user's calendar from wherever it already lives, without
becoming three separate integrations.
Approach. One
CalendarProviderinterface with three implementations — CalDAV,Google Calendar, Microsoft 365 — plus the two pieces they need and did not have: a
credential store and an OAuth handshake.
NoCalendarProviderkeeps "notconfigured" a normal case rather than a branch at every call site.
This PR is backend only. There is no UI change, so no Screenshots section.
The security decisions, because they shaped the code
Credentials sit outside the agent-reachable tree. They live under
<crew-home>/workspace/meetings/, notapp_data_dir("meetings"). That data dir isthe region
store.containbounds agent-supplied paths against, and a calendarrefresh token survives until revoked — an agent that could read the file would keep
reading the user's schedule long after the session ended. Keeping it outside removes
the reachability question instead of answering it.
security.pygains the path toits denylist, generated for both crew-home prefixes so a migration fallback to the
legacy data-home is covered too.
The address vet is
link_unfurl.vet_unfurl_url, reused rather thanreimplemented. This is the part worth reviewing closely, because it started as a
purpose-built check in
calendar.pyand that was a mistake. A calendar URL isoperator-supplied and fetched server-side, so this endpoint is an SSRF surface; a
second copy of the vetting logic is a second place to fix a bypass. The local copy
was also already weaker in four concrete ways:
0177.0.0.1177.0.0.1ipaddresswill not read the octal, so it fell through to DNS and getaddrinfo read0177as decimal. The vet and the connection disagreed about the target — the exact class of bug the pinning exists to prevent.100.64.0.1is_privatedoes not cover CGNAT; onlyis_globaldoes. On a machine on a tailnet, that range is the private network.fec0::1is_global..local,.onionlink_unfurlalso ownstest_vet_rejects_every_special_purpose_range, which pinsthe refusal set against a table of IANA special-purpose prefixes — so the next gap
is found by the suite instead of by a reviewer. A second implementation here would
not have inherited that.
One refusal went the other way and is kept, layered on top. A resolved address
that
ipaddresscannot read is refused, not skipped._reject_if_internal_ipreturns silently for a non-literal, because to it a non-literal is a hostname still
to be resolved; here the list is already a resolution result, so an unreadable entry
would reach the pin unchecked.
The pin, the redirect hop loop and the same-origin check stay local, and are not
duplication.
VettedUrl's own docstring says to pin the resolver onwire_host— this is the caller side of that contract.
resolveis injected for one reason:to keep every address the vet approved, since
VettedUrlreports one and amulti-homed calendar host should keep its fallbacks. Every address kept is one the
vet checked; it vets the whole answer, not just the address it returns.
Ports narrow to 80/443, which https-only leaves as 443. Stricter than "whatever
port the URL names", deliberately: a calendar on another port is nearly always an
internal service, and the port is the cheapest place to stop this endpoint being
used to probe for one. Nothing is broken by starting strict — this feature has never
shipped — and relaxing it later is one line.
XML parsing uses
defusedxmlwithforbid_dtd=True. The stdlibElementTree expands internal entities while refusing external ones, and
XMLParser.doctypeis ignored on 3.12, so hand-rolled stdlib hardening is nota reliable option (and the SAST gate
python.lang.security.use-defused-xmlfails the build on stdlib
xml.etreeanyway).defusedxmlis declared insetup.cfg— not a new runtime requirement in practice:doc_parserhasimported it since before this PR and the install only ever got it
transitively; this entry declares the dependency the tree already had.
Redaction is applied once, in
build_event(), rather than per provider. A newprovider then cannot forget it, and
security_posture.pygains one sink instead ofthree.
The OAuth redirect URI is derived from the request's own origin. Dashboard auth
is an HMAC-signed cookie scoped to the host, and the port is configurable — a
constant would break on any non-default port, and
localhostvs127.0.0.1wouldnot share the cookie.
Tests
Three files, ~1,800 lines of tests for ~1,700 lines of source.
test_meetings_providers.py— ICS parsing (escapes, TZID, DURATION, recurrencewindows), the address vet, and the pin.
TestDnsRebindingIsRefusedstands up areal loopback server on two families and asserts the fetch lands on the vetted
address rather than a rebound one; it lifts exactly two refusals (the
private-address rule, since 127.0.0.1 is the test server, and the 80/443 port
rule, since it binds ephemeral) and runs everything else — resolution, the
all-or-nothing check, the pin, the connector, the hop loop — for real.
test_meetings_oauth.py— the handshake: state, redirect-URI derivation, tokenexchange and refresh, and that a failure does not strand a half-written credential.
test_meetings_calendar_routes.py— the HTTP surface, including that aprovider error is a 4xx with a message rather than a 500.
Non-vacuous on the part that matters: reverting the delegation and restoring the
local vet fails by name on
0177.0.0.1,100.64.0.0/10andfec0::/10.Manual verification
The address vet was checked against the live code rather than only through the
suite — the four rows in the table above were run through
_normalize_urlbeforeand after the change, confirming each was approved before and is refused now, and
that a public host still resolves and pins normally (4 addresses, port 443) with
webcal://rewritten tohttps://.OAuth against live Google / Microsoft tenants is not verified here: it needs
registered client credentials for each, which this branch does not carry. The
handshake is covered by unit tests at the protocol level, and the redirect-URI
derivation — the piece most likely to be wrong in a real deployment — is tested
against non-default ports and both loopback spellings.
Related Issues
N/A — part of bringing the meetings app's calendar support up, not from a filed
issue.
Checklist
land with the UI PR, which is what a user will actually configure