feat(powers): browse, install and remove Kiro Powers (inert; activation split out) - #408
feat(powers): browse, install and remove Kiro Powers (inert; activation split out)#408kyleseaman wants to merge 1 commit into
Conversation
GPT 5.6 Review — 🔴 changes requested (blocking)GPT 5.6 found at least one blocking issue that must be resolved before merging This comment is updated in place on each push. BLOCKING -- src/kiro_crew/powers.py:1349 -- Filesystem probe runs on the event loop |
Design Review (Fable 5) — 🟡 CONCERNSAdvisory design-level review of Design-Verdict: CONCERNS Sound staged design, but the on-disk store diverges from the upstream IDE's layout at the cheapest moment to converge — before any user data exists. Watch
Suggestions
[DESIGN-REVIEWED] c319808 |
Opus 5 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
c96957f to
ab6743d
Compare
|
Round 1 dispositions — all four GPT 5.6 findings and all 10 CodeQL alerts addressed in HIGH — stale MCP entries survive a reinstall (
|
Arbiter — ⏳ review pendingArbiter is waiting for Opus-5 review output for Second-order review for False positive or not applicable? A repository writer can comment: For a broader accepted-risk deferral, apply |
ab6743d to
4ff7709
Compare
|
Round 2 dispositions — all three GPT 5.6 findings addressed in MEDIUM — marketplace powers were un-installable (
|
4ff7709 to
7e26ead
Compare
|
Round 3 — GPT 5.6 now passes. Addressed three items in Arbiter item 2 / Opus MEDIUM — cross-power purge prefix collisionFixed, and the fix needed a schema addition. Ownership is now authoritative rather than inferred: Worth noting the first attempt at this fix failed its own test: I filtered against Regression: Design Review finding 2 — marketplace cache in the shared temp dirFixed: the cache moved from This one is a direct consequence of my round-2 change and deserves flagging: before Opus LOW — handler pre-check on the event loopFixed for consistency: the handler's Verification
12 pre-existing failures remain, all reproduced on pristine Two items deferred pending a maintainer decision1. Arbiter item 1 / Design finding 1 — the knowledge half of a Power bypasses the trust gate. Confirmed and I am not disputing it: The fix direction is clear (materialize on enable, remove on disable/untrust) but there is a real product question inside it: should enabling a Knowledge Power require a trust grant? Today trust means "may this run a local subprocess". If prompt injection into agent context is also in scope, trust should gate docs too — which adds a consent click for docs-only Powers and diverges further from the IDE's one-click flow. That is a maintainer call, not mine, so I have not guessed at it. 2. The last CodeQL alert (1 high, Once both are settled I will land them together with |
7e26ead to
e19a918
Compare
|
Round 4 — all four GPT 5.6 findings plus all three Arbiter items addressed in HIGH — sensitive-path check covered only the selected root (
|
c0856ff to
34186e1
Compare
|
Round 5 — all five GPT 5.6 findings plus Claude's advisory MEDIUM addressed in HIGH — blocking I/O on the event loop (
|
Follow-up: evidence for the "Windows red is inherited" claimMy round-5 comment asserted the
So the Windows failures on this PR are inherited from the base branch, and the tracked fix is open PR #444 ( |
34186e1 to
9655373
Compare
|
Round 6 — all three GPT 5.6 findings addressed in HIGH — rollback restored only the bundle tree (
|
9655373 to
94e514c
Compare
|
Round 7 — all nine GPT 5.6 findings addressed in HIGH — Powers state was not write-protected (
|
d7fbc57 to
7c075da
Compare
Round 8 disposition —
|
7c075da to
a12007d
Compare
Round 18 —
|
Round 19 —
|
Round 20 —
|
Round 21 —
|
Round 22 — the three stale browse screenshots, and what re-shooting them foundHead The defectAll three provider bodies were read with one
Neither failure looked like a read bug:
Both surfaced as provider unavailable, so the browse view rendered an empty catalogue against the real registry. The per-file byte cap was also unenforceable: a short read never reached it, so an oversized file was accepted as a truncated one rather than rejected. Fixed with Why 95 tests missed itEvery existing test stubs Consequence: provider overlap became reachableThe official provider had never returned data in practice, so nothing about the merge was observable. It is registered first, and dedup is by canonical repository URL, so for the 26 of 82 overlapping Powers the official entry won — and the official provider lists a GitHub directory, so The losing duplicate now donates any facet the winner left blank; a field the winner populated is never overwritten.
ScreenshotsAll three re-captured and re-pinned to
The stale shot was taken with the marketplace upstream made unresolvable and its disk cache aged past the TTL, so the provider takes the real transport-failure branch and serves expired entries while the official provider keeps working — the caption says so. Verification
Scope noteThis is a behavioural fix inside the PR's own provider layer, not new surface: no route, schema or UI control was added, and installed Powers remain inert. It is in scope because the PR's central claim — that you can browse the registry — was false against the real upstreams. |
Round 23 —
|
Round 24 — publisher icons (
|
| Signal | In the page | We extracted |
|---|---|---|
| Publisher icon | 76 of 76 cards | ✗ (now ✓) |
| Per-card description | ✗ — no description text in any card | — |
| Per-card category | ✗ — category exists only as a filter control | — |
Card anchor id="<slug>" |
✓ on all 76 containers | ✗ (still using the launcher window) |
Two things worth recording from that. The blank descriptions on our cards are faithful to upstream, not a parsing gap — the card DOM is icon, title, author, launcher, Details link, and nothing else. And there is a stable per-card anchor (id="<slug>"), which would be a simpler parse than the neighbour-bounded launcher window the spec calls fragile; I have not switched to it in this PR, but it is now written down rather than left as an unknown.
The icon URL is treated as untrusted input
It is scraped from a third-party page and becomes an img src, so the origin is an allowlist at a single chokepoint (marketplace.valid_icon_url): https only, exactly the Kiro asset host, /powers/icons/ path prefix, length-bounded. Refused: scheme downgrade, foreign host, suffix-spoofed host (prod.download.desktop.kiro.dev.evil.com), path escape, javascript:.
Validation runs twice — on scrape and on cache read. The second is the one that matters: the marketplace cache is on-disk state this spec already treats as attacker-reachable (it decides which repository an id resolves to, which is why the resolved URL is re-validated on read). An icon read back from a poisoned cache would otherwise put an arbitrary origin in front of the user's session on every card render. A refused icon degrades the card and never drops the Power.
No CSP change was required — img-src 'self' data: blob: https: already permits it. I checked before building rather than after.
Why this also vindicates the facet merge
The official provider lists a GitHub directory, so it has no icon, and it wins the dedup for the 26 overlapping Powers. Without the blank-facet enrichment from round 22, a third of the catalogue would render the fallback while the marketplace held a real icon for it. Measured on the live registry: 76 of 82 cards now show a real icon; the 6 that do not are official-only Powers with no marketplace card, and they render the Kiro Powers mark. aws-mcp in the screenshot is one of them.
Verification
- 1,485 backend tests pass on the
powers or security or provider or data_homeselection (13 new) - 4,754 frontend tests, 412 files, all green;
tscclean - Revert-verified: defeating the allowlist fails 7 of the 13 backend tests, including the poisoned-cache one; removing the render fails all 3 frontend tests
isort,flake8,mypyclean- Three browse screenshots re-captured. The harness now counts icons with
naturalWidth > 0, so a shot full of fallback placeholders fails the capture instead of being committed — the same discipline the inert-surface assertions use.
Scope
This is new surface on an already-large PR, added at the maintainer's explicit request after reviewing the alternative (ship #408 first, icons as a follow-up). It touches the provider dataclasses, the marketplace parser, the API shape and one card component; it does not touch install, remove, the transaction, or the inert-install property.
Round 25 —
|
| Operation | What a swapped root did |
|---|---|
staging.mkdir(parents=True) |
created the staging tree outside the store |
self._write_installed(...) |
wrote the record describing the store outside it |
shutil.rmtree(target) inside _rmtree_at |
reopened each component by name and deleted an external tree |
All four mutation classes are now descriptor-anchored: _mkdir_at, _atomic_write_at (temp + os.rename with src_dir_fd/dst_dir_fd), and _rmtree_fd — a hand-written dir_fd recursive delete, because shutil.rmtree gained dir_fd later than the Python versions CI runs. The previous form validated only the top-level entry through the descriptor and then handed the path to shutil, which is the window.
The copy is anchored too, which the finding did not name but the same reasoning reaches: creating staging through the handle is pointless if the copy then writes by path, because the staging name is pid-derived and therefore guessable, so a decoy of that name could receive the bundle. _copy_power_files / _copy_regular_file now take a dest_fd and create files with O_CREAT|O_EXCL|O_NOFOLLOW relative to it.
_drop_record and _write_installed take the handle as a keyword so read-repair callers outside a transaction keep the plain path write. _SUPPORTS_DIR_FD still gates all of it; Windows keeps the path forms.
The new test drives a root swap during the remove transaction with a decoy root laid out to make lexical operations succeed — an installed.json to overwrite and a .removing-kb tree to delete. With the fix reverted it fails on the recursive delete followed the swapped root, so the damage is demonstrated rather than asserted.
FINDING — function-local provider imports: fixed, not rebutted a fourth time
I declined this three times because the imports sit in try/except blocks implementing documented degradation, and I argued hoisting would turn an empty-stale fallback into an import-time failure.
That argument only holds against a plain module-scope import. A module-scope try/except satisfies the rule and keeps the behaviour: the import is attempted once at load, a failure is caught there, _PROVIDERS_AVAILABLE records it, and each handler checks the flag instead of catching its own ImportError. All four sites are gone; the sentinel exception type is preserved so the except clauses still never match when the package is missing.
The three Windows failures were mine, and the cause is a lesson
test_remove_keeps_the_record_when_deletion_is_impossible, test_cancel_after_staging_restores_the_bundle and test_cancel_compensation_does_not_depend_on_awaiting failed on shard 3 while passing on Linux. They patched shutil.rmtree and os.rename — the concrete calls, which differ by platform: the anchored path deletes via _rmtree_fd and renames with descriptors, the fallback uses shutil.rmtree and os.replace. So each test exercised the seam on one platform and silently no-op'd on the other.
They now patch _rmtree_at / _rename_at, the module helpers every platform routes through. One of the wrappers was also taking the wrong positional args (_rename_at takes four), which is why the event never fired and the test timed out rather than failing an assertion.
The hoist moved a second patch seam the same way: four handler tests patched kiro_crew.powers_providers.<fn>, which no longer reaches the handler now that it holds its own reference. They patch kiro_crew.dashboard.handlers.powers.<fn> instead. Same lesson twice in one round — patch the name the code under test actually calls.
Verification
- 1,486 pass across the
powers or security or provider or data_homeselection; 83 intest_powers.py isort,flake8clean onsrc/andtest/;mypyclean across 495 files (the one localvector_memoryerror is a faiss-present artefact CI does not see)- Revert-verified: reverting the anchoring fails the new test with external damage; the four repointed handler tests fail against the pre-hoist patch target
- No frontend change, so
vitest/tscwere not re-run docs/system-specs/modules/powers.mdcorrected — the confinement paragraph now enumerates all five anchored operations instead of claiming only renames were the concern
Round 26 —
|
Round 27 —
|
Round 28 —
|
Round 29 —
|
Round 31 — deferred activation redesigned around the IDE's actual model (
|
| Upstream tool | Shape |
|---|---|
listPowers |
installed Powers with mcpServers names and keywords, without starting anything |
usePower |
(powerName, serverName, toolName, arguments) — namespaced dispatch |
readPowerSteering |
(powerName, fileName) — one .md, on demand, path-validated |
configurePowers |
ships behind ENABLE_WEBUI = false |
Their steering rules turn out to be independently identical to ours: .md only, no separators, no leading dot, validated join against the steering directory.
Why this matters for this PR, not just the follow-up
The push design's requirements were the reason the combined PR could not converge — nearly all of the transactional complexity existed to make enable/disable/revoke safe. Following the pull model deletes those requirements rather than solving them:
- The
includeMcpJsonconstraint that dominated the old plan is gone. Nothing is written to agent config, so nothing must be rebuilt and enable cannot be a silent no-op. (That was the real bug the split surfaced.) - No
power-<power>-<server>namespace, so the injectivity problem and the ownership record a non-prefix purge required both vanish. - No skill materialization, so third-party markdown never enters a session unasked and never competes for the steering budget.
- No
trusted/enabledfields — soinstalled.jsonas shipped in this PR needs no migration. I had written the opposite into the spec ("when activation lands it adds them, and its readers must treat a missing key asfalse"). That obligation is explicitly withdrawn in the spec rather than quietly dropped. - No enable/disable transaction, so the install/remove transaction that rounds 20–29 hardened does not grow two more state transitions over the same lock.
Consent, and one deliberate divergence
The trust flag is replaced by the approval mechanism the rest of the product uses: the first power_learn / power_use for a Power in a session raises an approval naming the Power, the server and the resolved command; per-session Trust covers the rest of that session. This is the same conclusion PR #518 reached when its round-4 change deleted slot._trust / _enforce_trust_ttl and routed through core approvals — a second bespoke trust store here would repeat the mistake that change corrected.
Upstream has no consent prompt: install is one click and the tools are callable. KiroCrew adds one anyway, because the IDE always has a human at the keyboard while KiroCrew runs turns from cron, Slack and Discord. The spec records this as intentional so a later reviewer does not "fix" it toward parity.
One requirement I nearly lost, and kept
Rewriting the section initially dropped the push plan's item 5, the bash-layer write guard. It belongs more under the pull model, with a different trigger: mcp.json becomes the source of an argv KiroCrew spawns, and it is read on every call rather than once at enable, so a prompt-injected shell write to powers/<name>/mcp.json is command injection wearing the Power's name. Restored with that rationale.
Layout divergences, now recorded as choices rather than unknowns
Upstream: ~/.kiro/powers (KIRO_POWERS_HOME overrides), bundles under installed/<name>/, and a versioned envelope — {version: "1.0.0", installedPowers: [{name, registryId, autoInstalled?}], dismissedAutoInstalls: [...]} — plus registries/, registry-repos/, repos/. Here: our data home, a name-keyed map with {kind, ref} provenance, bundles at <name>/. Different trees, so no collision — and no interop, which is the first thing to revisit if kiro-cli ships a native Powers runtime. Their Power type carries iconUrl, the field this PR's cards already use.
Verification
- Spec-only change:
docs/system-specs/modules/powers.md(+167/−10). No source file touched. - 177 Powers tests still pass;
flake8clean. - The round-30 finding (
assert record is not Noneafter the lock is released, so a concurrent delete makes a committed install report 500) is not addressed in this push. It is legitimate and small — build the response record inside_transaction— and is the next thing I will do unless the maintainer would rather see it separately.
Round 32 —
|
Round 33 —
|
Round 35 —
|
Round 36 —
|
| Stale record (current ordering) | Orphaned bytes (prescribed ordering) | |
|---|---|---|
| Visible to the UI? | No — load_power returns None, so list_powers hides it |
No |
| Findable at all? | Yes — the name is in installed.json |
No — list_powers enumerates installed.json, so a tree with no record is invisible |
| Reclaimed? | Yes, automatically (below) | Never |
What was fair in the finding is that my previous answer — "load_power reports it as missing and retrying clears it" — put the repair on the user. So the residual is closed from the other end: _prune_absent_records runs at the start of every transaction, so the next install or uninstall of any Power repairs it. The stale record is now self-healing by construction rather than by someone happening to retry the exact Power that was interrupted.
Reconciliation skips names with a pending .removing-* or .backup-* tree, because those are mid-recovery rather than absent. That distinction is not theoretical: running the prune before the orphaned-backup recovery dropped the record of a bundle sitting in the rollback slot, and test_orphaned_backup_is_recovered_on_next_install caught it immediately.
Two tests, both revert-verified: one drives the interruption and asserts an unrelated Power's removal repairs it (reverted: stale record was not reconciled), one asserts a bundle awaiting restore keeps its record.
The other two failures produced no findings
- Opus 5 Review — cancelled at the 30-minute ceiling,
outcome=cancelled, failing closed. No code finding emitted. Same stall class seen on feat(chat): ask_question tool — blocking clickable question card #464 and fix(approvals): stop background approvals hijacking unrelated conversations #612. - Design Review — harness error,
result is_error:true, run did not complete. No finding emitted.
Both need a re-run rather than a code change, and I have re-triggered them.
Verification
- 1,561 pass across the
powers or security or provider or data_homeselection isort,flake8,mypycleanBackend Tests (Windows) (2)cleared on its own — that was the inheritedtest_file_change_snapshots.pyPOSIX-/tmpfailure, in neither PR's diff
UX Review (Fable 5) — 🟡 CONCERNSAdvisory UX-level review of UX-Verdict: CONCERNS Usable and honestly-stated, but the browse flow has a lying counter, a misleading scope chip, and installs that yank you out of browsing. Watch
Suggestions
[UX-REVIEWED] c319808 |
Round 37 —
|
Round 38 —
|
Round 39 —
|
Round 40 —
|
Round 41 —
|
Adds a Powers surface to the left rail's Apps group: browse the upstream
registry (official `kirodotdev/powers` monorepo + a mirror of the
`kiro.dev/powers` marketplace), install a bundle, list what is installed,
and remove it.
Installed Powers are INERT, and that is the point rather than an omission.
An installed bundle is unreachable by construction:
* No MCP server is registered. The bundle's `mcp.json` is never parsed
for specs — only tested for presence, to label the Power `mcp` vs
`knowledge` — so a declared command has no path to
`_set_kirocrew_entry` or any other execution site.
* No skill is materialized. `POWER.md` and `steering/*.md` stay inside
`powers_dir()`, which nothing else reads: `skills.py`, `context.py`
and `agent.py` contain no reference to it. Third-party markdown
cannot enter agent context.
Consequently `installed.json` carries no `trusted`/`enabled` field: a
trust flag that gated nothing would assert a control the code does not
implement. The UI says so plainly — an Inactive badge and a note that
nothing is registered and no guidance is loaded — instead of showing a
dead toggle. Tests assert the ABSENCE of any activation affordance.
Activation (trust grant, enable/disable, `power-<power>-<server>` MCP
registration with ownership-exact purge, the generated docs skill) is a
follow-up PR. It is not a flag flip: `includeMcpJson` is false, so
sessions read the rendered `~/.kiro/agents/kirocrew.json` and every
existing MCP mutation path rebuilds it. An activation that wrote only
`<data home>/mcp.json` would be a no-op on enable and would leave a
revoked server live. `docs/system-specs/modules/powers.md` records that
constraint under "Deferred: activation".
Security controls retained here:
* `fetch.py` — HTTPS-only host allowlist, no `git clone`, traversal and
symlink rejection, byte/file/depth bounds, bounded timeout, temp tree
removed on every failure path including cancellation.
* Allowlist copy of exactly the contract files, so pointing a `folder`
install at an allowed ancestor cannot relocate `~/.ssh` under the
powers dir.
* `resolve_install_source` refuses sensitive paths and audits the
denial (the success-path SEL event would not fire on a refusal).
* `powers/` is write-protected at the agent file-edit gate:
`.marketplace-cache.json` decides which repository an id resolves to,
and `installed.json` is the provenance shown in the UI. Reads stay
allowed.
* Atomic install (stage / rollback / commit) with the staged `POWER.md`
re-parsed under the lock, since the source is caller-owned and
mutable; remove destroys the staged bytes before dropping the record,
so a failed delete can never orphan a bundle.
Marketplace parsing is bounded by neighbouring launchers rather than a
fixed window (a fixed window pairs each card with its predecessor's
repository), provider failures surface as stale-with-banner rather than
as an empty catalog, and unavailability is not latched.
Install and remove are each ONE blocking transaction: a single callable
that takes the cross-process lock, performs every mutation, rolls itself
back on failure, and releases the lock, awaited under `asyncio.shield`.
Compensation that lives in the coroutine cannot be made correct — a
cancelled task re-raises from every subsequent await (proven on 3.10 in
CI), so awaited compensation silently never runs, while waiting
synchronously to avoid that stalls the event loop. With one worker there
is nothing to compensate from outside and nothing to settle, so
`_submit_blocking`, `_settle_for_compensation`, `_settle_blocking`,
`_run_drained` and `_powers_transaction` are deleted rather than tuned.
Confinement comes from a pinned descriptor rather than a check:
`_root_lock` opens the powers root `O_NOFOLLOW|O_DIRECTORY` once and
every rename and delete goes through it, so a root swapped for a symlink
mid-transaction cannot redirect a mutation onto files outside the store.
That retires `_assert_root_not_symlinked`, which was one operation away
from the mutation it guarded. `_SUPPORTS_DIR_FD` gates the POSIX path;
Windows keeps the path-based check, where creating a symlink requires
elevation.
Every provider body is read through a looping `read_capped` helper rather
than one `StreamReader.read(cap + 1)`. `read(n)` returns whatever is
buffered when it wakes and does not loop to fill n, so a single call
truncated any body larger than one wire chunk: measured live, 57 KiB of a
649 KiB marketplace page and 9.8 KiB of a 25.6 KiB GitHub JSON document.
The symptoms were not read errors but provider errors — the scrape found
zero cards, the official provider failed `json.loads`, both surfaced as
"provider unavailable", and browsing the real registry returned nothing.
The per-file byte cap was equally unenforceable, since a short read never
reached it. The unit suite stubs all three HTTP seams, so the regression
tests run against a loopback server.
Because that fix makes the official provider reachable for the first
time, provider overlap now matters: dedup is by canonical repository URL
with provider order deciding ownership, and the losing duplicate donates
any facet the winner left blank. The official provider lists a GitHub
directory, so `description`/`author`/`category` are structurally empty
for it and overlapping cards previously rendered authorless. `scope` is
deliberately not merged — the providers disagree (monorepo membership vs
authorship facet) rather than one being blank — so an AWS-authored Power
mirrored into the official monorepo answers to the Official chip while
its card shows AWS as the author. The spec records that divergence.
Registry cards carry the publisher icon the marketplace listing already
exposes (76 of 76 cards). The URL is scraped from a third-party page and
becomes an `img` source, so its origin is an ALLOWLIST enforced at one
chokepoint, `marketplace.valid_icon_url`: https only, exactly the Kiro
asset host, and a `/powers/icons/` path prefix. It is applied both when
scraping and when reading the disk cache back, because the cache is
on-disk state the spec already treats as attacker-reachable, and a
refused icon degrades the card rather than dropping the Power.
Icons also justify the blank-facet merge added earlier: the official
provider lists a GitHub directory and has no icon to report, yet it wins
the dedup for the 26 overlapping Powers, so without enrichment a third
of the catalogue would render the fallback while the marketplace held a
real icon for it. The client falls back to the Kiro Powers mark when the
field is absent and again when the host fails to serve the image.
Every mutation inside a transaction is anchored to the pinned root
descriptor, not just the renames: the staging mkdir, the recursive
delete, the contract-file copy and the `installed.json` write. The gap
was not theoretical — a lexical `shutil.rmtree` walk reopens each
component by name, so a root swapped mid-walk deleted an external tree,
and a lexical state write put the record describing the store outside
it. The recursive delete is hand-written against `dir_fd` because
`shutil.rmtree` gained that parameter later than the Python versions CI
runs, and the copy receives a descriptor for the staging directory so a
decoy of the same pid-derived name cannot receive the bundle.
Provider imports in the dashboard handler moved to module scope inside a
`try/except`, which satisfies the top-level-imports rule while keeping
the degradation the function-local form existed for: the failure is
caught once at import, and each handler checks a flag instead of
catching its own ImportError.
Transaction state READS are anchored too. An anchored write over a
lexical read is worse than neither: the record lands in the right place
with content read from a decoy, so an install rebuilds `installed.json`
from foreign state and erases every other Power's provenance. The
read-only `list_powers` / `load_power` paths keep the plain read, where a
decoy yields a wrong listing but cannot destroy state.
The install SOURCE is pinned as well as the store. `steering/` is opened
once with `O_NOFOLLOW|O_DIRECTORY` and enumerated through that handle,
and contract files are opened relative to a pinned source descriptor: a
check-then-glob re-resolves the directory name, and per-file
`O_NOFOLLOW` does not object to what it then finds, because after a swap
those are ordinary regular files that merely live outside the source the
caller offered.
Crash recovery no longer destroys bytes. With both `dest` and
`.backup-<name>` present the interrupted transaction's intent is not
recoverable from disk, so the backup is quarantined under a timestamped
name with a warning instead of deleted — it may be the only copy of the
bundle the live record still describes. On the remove side a leftover
`.removing-<name>` is reconciled before the record is dropped, and if it
still cannot be deleted the record is kept, so bytes are never left
untracked.
`github_url` is re-validated on cache read as well, and the Source link
is routed through `safeHttpUrl` and omitted when rejected. The icon URL
gained that treatment earlier while the repository URL did not, which was
the worse omission of the two: an icon becomes an `img src`, whereas the
repository URL becomes an `href` the user clicks, so a poisoned cache
entry would have been click-to-execute. The backend skips such an entry
rather than blanking the field, since the URL is both the dedup key and
the install target and a card without a trustworthy one is not usable.
Two gaps in that confinement code are closed as well. The state write now
uses a random temp name with `O_CREAT|O_EXCL` instead of a fixed
`.installed.json.tmp` with `O_TRUNC`: `O_NOFOLLOW` refuses a symlink at
that path but says nothing about a hardlink, so a preplanted link would
have been truncated and then filled with store state. And the root check
rejects Windows directory junctions, not only symlinks —
`Path.is_symlink()` returns False for an NTFS reparse point, and the path
check is the only guard on the platform without dir_fd support, so the
one platform that depended on it had the incomplete test.
The deferred activation design is rewritten around the model the IDE
actually uses. Reading `kiro-team/kiro-extension` shows upstream neither
merges a Power's MCP servers into agent config nor injects its docs into
context: it exposes `listPowers` / `usePower(power, server, tool, args)` /
`readPowerSteering(power, file)` and the agent pulls. Following that shape
deletes rather than solves the hardest parts of the previous plan — the
`includeMcpJson` rebuild constraint, `power-<power>-<server>` namespacing
with its ownership-exact purge, skill materialization, and the
`trusted`/`enabled` fields, which means `installed.json` as shipped here
needs no migration. Consent becomes a per-session approval naming the
resolved command, matching the decision PR #518 reached when it deleted
bespoke trust grants. Recorded in the spec under `Deferred: activation
(pull model)` and `Upstream Powers implementation (observed, not
inferred)`, with the observed upstream layout and the divergences from it
tabulated as choices.
The install response record is read back inside the transaction, while the
lock is still held. Reading it afterwards left a window in which a
concurrent `remove_power` for the same name could drop the record, so an
install that had already committed reported a failure — and under
`python -O`, where the old assertion is stripped, the same window
produced `None["kind"]` instead. `load_power` takes the transaction's
pinned root handle for its provenance read so an in-transaction read is
anchored like every other one, and the impossible case now raises a
typed error rather than asserting, because an assert is not present under
optimisation.
Uninstall prepares its record write before destroying the bundle. The
write previously ran only after the delete, so a full filesystem could
succeed at deleting and then fail with ENOSPC, losing the bundle while a
stale record remained. `_stage_write_at` allocates and fsyncs the new
`installed.json` up front and `_commit_staged_at` finishes with a rename
inside one directory, which needs no space. The ordering the spec commits
to is unchanged — the record is still not committed until the bytes are
gone, so a failed delete cannot orphan a bundle — while the window that
ordering left open is closed. An uncommitted staged file is discarded on
every failure path. Platforms without `dir_fd` keep the unsplit write and
its residual window, recorded rather than hidden.
Rebased onto main. Two integration points needed real resolution rather
than a mechanical merge: `surfaces/builtins.tsx` gained a
`selectSubagentActivityCount` import alongside this branch's `PowerIcon`
(both are used, so both are kept), and main's new security-posture drift
guard requires every redactor call site to be either a registered sink or
an explicitly-reasoned allowlist entry. The two Powers egress boundaries
are registered as sinks — the provider listing, which redacts scraped
marketplace metadata, and the Powers HTTP handlers, which redact every API
body — while `powers_providers/redact.py` is allowlisted because it only
defines the helpers, exactly as `security.py` does for the base scanners.
Record staging now runs on every platform, not only where `dir_fd` is
available. The previous revision applied it on POSIX and recorded the rest
as a known residual — but that residual was a real data-loss window kept
for convenience: staging needs a temp file in the same directory and an
atomic replace, and `os.replace` provides both on Windows. Confinement and
durability are now independent concerns: the pinned descriptor is still
POSIX-only and still gates confinement, while the record is durable before
the bundle is destroyed everywhere. `atomic_write` is no longer imported
here because stage-then-commit replaces it on both branches.
Records with no bundle are reconciled at the start of every transaction.
The remove ordering deliberately destroys the bytes before committing the
record, so an interruption in that window leaves a record naming a Power
whose files are gone; `load_power` already hid it, but "retry and it
clears" put the repair on the user. `_prune_absent_records` now repairs it
on the next install or uninstall of any Power. Names with a pending
`.removing-*` or `.backup-*` tree are skipped because they are
mid-recovery, not absent — running the prune before the backup-recovery
step stranded a bundle awaiting restore.
The alternative the reviewer prescribed, committing the record before
deleting, is declined for the fifth time on the same asymmetry: a stale
record is discoverable and now self-healing, while orphaned bytes are
neither, because `list_powers` enumerates `installed.json` and a tree with
no record is invisible to it.
Round 37: uninstall now commits the record BEFORE destroying the bundle.
This reverses the ordering the previous rounds defended, because the
objection that kept it no longer holds. The refusal rested on orphaned
bytes being unreclaimable -- `list_powers` enumerates `installed.json`, so
a tree with no record is invisible to every reader. That is true of a
bundle left at its own name. It is NOT true of one renamed aside first:
`.removing-<name>` is derived from the record's own name and sits directly
under the root, so `_sweep_orphaned_removals` finds it by prefix with no
record to point at it. The rename-aside that was already there is what
makes committing first safe.
What the old ordering cost: it protected against a crash but made a FAILED
uninstall destructive. With the bundle already deleted, a record
replacement that fails -- a held-open `installed.json` on Windows being the
concrete case -- returned an error for a Power that was in fact gone, and
the bytes were unrecoverable, so no reconciliation could undo it. Now the
commit is the only step that decides the outcome: before it, a failure
restores the tree and loses nothing; after it, the Power is removed and a
failed delete is reclaimable garbage that is logged and swept, not raised.
`_reconcile_store` runs both repairs at the start of every transaction --
`_prune_absent_records` for records with no bundle (still reachable via a
hand-deleted bundle) and `_sweep_orphaned_removals` for the new residual. A
`.removing-*` whose name IS still recorded is left to `remove_power`, which
finishes that interrupted delete deliberately. A bundle with no record at
all is still deleted strictly: nothing is authoritative there, so a failed
delete is a failed removal.
Three tests pinned the old contract and are re-expressed rather than
deleted, each saying in its docstring what changed and why. The inverted
one (`a failed delete keeps the record`) is now
`test_failed_delete_after_commit_reports_success_and_is_reclaimed`, and its
original intent -- a failed removal must lose nothing -- moved to
`test_failed_record_commit_restores_the_bundle`, which is where the
property actually lives now. Both fail on revert, the second with
"record lost on a failed removal": the destructive behaviour itself.
Also fixed, from the rebase onto current main (121 commits):
- `App.test.tsx` asserted the rail's overflow count as the literal
"4 more". Adding an Apps surface makes it 5. Matched by shape now, since
the test is about dismissing the hover label, not about the count.
- main added a gate requiring every built-in surface to carry an i18n
`labelKey`. Powers had none, so `nav.powers` is added to the surface and
to both catalogues. The Chinese label is a best-effort term and a native
reviewer should confirm it.
Coverage Gate needed no change: it failed closed on the frontend job, not
on a coverage threshold.
Also round 37: a same-named Power from a DIFFERENT source no longer
silently replaces the installed one.
A Power's identity is the `name` in its `POWER.md` and nothing reserves it
globally, so a monorepo directory and an independent author's repository can
both declare `kb`. Installing the second replaced the first bundle AND its
provenance record, leaving the store describing a Power the user never
chose, from a source they never picked. That is now `PowerSourceConflict`,
mapped to 409 -- the request is well-formed and the STATE is the problem, so
the caller can act on it, which a 400 would misreport and the generic 500
arm hid entirely behind "install failed". The dashboard already unwraps
`{"error": ...}` and renders it, so the reason reaches the user unchanged.
The rule is narrower than the prescription, deliberately, and the tests are
what forced it: `folder` -> `folder` is exempt because that is the
development loop -- reinstalling from a rebuilt or relocated directory --
where the path is incidental rather than provenance and there is no third
party to impersonate. Rejecting it broke two existing reinstall tests for no
security gain. Every other combination involves at least one remote source,
including both directions between `folder` and `registry`, since losing a
recorded upstream to a local directory of the same name is the same loss.
Same-source reinstalls stay allowed, so the upgrade path is untouched.
The check runs inside the transaction and after crash recovery: before the
lock it would race a concurrent install, and before recovery it would
compare against a record whose bundle is about to be restored.
Five tests, revert-verified against the check alone rather than against the
missing exception class -- reverting the class makes the suite fail with
AttributeError, which would pass for any rename. The handler test drives the
real `install_from_dir`; a first version patched
`materialize_power_bundle`, a name this handler never calls, so nothing was
patched and the assertion read a 400 from the unpatched fetch.
Round 38: the interrupted-uninstall RETRY branch uses the durable path too.
Round 37 changed the main path to commit the record before destroying the
bundle but left the recovery branch on the old ordering, and that branch is
the worse place for it: it handles the state left by a process that died
after the rename-aside, where `.removing-<name>` is the ONLY copy of the
bytes. It deleted them and dropped the record afterwards, so a record
replacement that failed -- a held-open `installed.json` on Windows again --
destroyed the bundle with nothing to restore from.
The branch now splits on whether a record still claims the name. If it does,
the tree is renamed back to its own name and execution falls through to the
durable path, which commits while the bytes still exist. If it does not, the
removal already committed and the leftover is swept and reported as already
removed, since raising there reports a failure for a removal that succeeded.
`test_record_survives_when_the_leftover_tree_cannot_be_removed` asserted the
contract this replaces ("a failed delete keeps the record") and is
re-expressed as
`test_retrying_an_interrupted_uninstall_cannot_destroy_the_bundle`, which
pins the property that actually matters: a failed retry loses nothing. It
fails on revert with "the only copy of the bundle was destroyed". A second
test covers the other half of the branch.
Also round 38: hardlinked contract files are refused on install.
The sensitive-path refusal is path-based, so a hardlink walked around it:
`steering/guide.md` sharing an inode with a protected file was copied into
the Powers tree, where an agent can read the bytes back through
`power_steering`. Nothing existing caught it -- the symlink guard compares
`lstat` identity against the opened `fstat`, and a hardlink IS the file, so
they agree and there is no link to detect. `st_nlink > 1` is now rejected,
which is the rule `hooks.safe_read_file_bytes_nolink` already applies on the
read side and the reason the state write uses `O_EXCL`.
Two tests, the refusal one revert-verified; the second pins that an ordinary
single-link bundle still installs, since the guard's risk is over-rejection.
Round 39: every POWER.md read is capped, not just checked after the fact.
`parse_power_md` has always refused over `MAX_POWER_MD_BYTES`, but it did so
after `read_text()` had already pulled the whole file into the process. The
size is not fixed at install time either: the installed bundle stays
editable, and the install source is caller-owned before any copy budget
applies. So anything able to write into a bundle could exhaust memory in the
MCP process through `power_list`, and the guard that was supposed to stop
that was downstream of the allocation.
All three read sites now go through `_read_power_md`, which reads one byte
past the cap and refuses rather than truncating -- a truncated `POWER.md`
either fails frontmatter parsing with a confusing error or, worse, parses as
a different Power than the file describes.
The first version of these tests asserted the REFUSAL, which is not new: all
three passed with the fix reverted. They asserted the wrong thing because the
observable behaviour is unchanged -- what changed is which read path runs. They
now ban `Path.read_text` for POWER.md with a `RuntimeError` sentinel (not
`OSError`/`PowerFormatError`, either of which `load_power` swallows and which
would therefore be indistinguishable from the capped refusal), and three of
the four fail on revert with "unbounded read of <path>". One test covers the
ordinary-size case too, so a fix that only capped after a size check would
not satisfy the suite.
Also: main added bn/es/fr/hi/pt/ru locales since the last push, and
`catalogParity` requires every catalogue to cover `en` exactly. `nav.powers`
is added to all of them in English, and the Chinese guess from round 37 is
reverted to match: Powers is the Kiro feature name, and `i18n-shard.mjs`
reports keys identical to English as "expected for proper nouns/product
names". A native speaker should still make the call per language.
Round 40: the same cap for `mcp.json`.
`_declares_mcp_servers` read the whole file, and it runs on `GET /api/powers`
for every installed Power. Same reachability as the `POWER.md` case: the
bundle stays editable after install, so the copy-time budget does not bound
what is read later. A dedicated `MAX_MCP_JSON_BYTES` (256 KiB) rather than
the 8 MiB whole-bundle budget, because this file is a server map, not a
document.
Over-cap returns "declares nothing" instead of raising. This function answers
a yes/no question used to label a card in the listing, and install-time
validation is what refuses malformed bundles -- raising here would let one
oversized file break the whole listing.
Both tests ban `Path.read_text` for `mcp.json` and fail on revert, including
the ordinary-size one so the capped path cannot be a branch taken only when
the file is already large.
Round 41: a junctioned source `steering/` is refused, not followed.
`Path.is_symlink()` is False for an NTFS directory junction, so the
no-descriptor branch's check fell through to `is_dir()` -- which a junction
satisfies -- and globbed the target, copying Markdown from outside the
selected source into the agent-readable Powers tree. Same class as the
reparse-point guard already at the store root, now applied on the branch that
depends on a path check; the POSIX branch pins the directory with
`O_NOFOLLOW` and never reaches it.
A symlinked `steering/` is now refused rather than silently skipped. Skipping
looked safe and is exactly what let the junction through, and it would also
install a partial bundle from a source tampered with mid-install.
The tests drive `_copy_steering` directly, and the first version did not.
Going through `install_from_dir` proved nothing twice over: the install path
pre-validates the source and already refuses a *symlinked* `steering/` with
its own error, which a loose `match="symlink"` accepted with this fix
reverted, and the POSIX branch never reaches the path check anyway so the
`_SUPPORTS_DIR_FD` patch did not select the branch under test. The junction
itself is not constructible on Linux -- `is_symlink()` being False for one is
the defect -- so one test simulates that shape and asserts the reparse check
is consulted before the glob. Both fail on revert.
Round 42: the POWER.md existence probe moves into the executor.
`install_from_dir` stat'ed the source `POWER.md` on the event loop before
handing the rest of validation to `maintenance_executor()`. A single stat
looks free next to a tree walk, but the source is API-supplied and can be a
network mount, where that stat blocks for as long as the mount takes to
answer -- stalling chat, the heartbeat and every dashboard request, not just
this install. The probe, the tree walk and the parse are now one blocking
callable, so there is no ordering left in which a caller-supplied path is
touched from the loop.
The regression test asserts the THREAD the probe runs on rather than latency:
a timing test would be flaky and would not identify which call was at fault.
It captures the loop's thread id in the coroutine, so any probe recorded on
it is by definition blocking the gateway. It fails on revert. A second test
pins that the caller-visible error for a missing POWER.md is unchanged.
Also fixes one of my own round-39 tests, which failed on the Windows shards
only: it pads `POWER.md` to just under the cap, and text-mode writes translate
every newline to CRLF on Windows, inflating the file past the cap. The write
is now byte-exact with `newline=""`.
Round 42 —
|
|
Hey @kyleseaman 👋 This PR has merge conflicts and is currently 375 commits behind main — main has moved significantly since your last push on Jul 30. The What's needed to move forward:
Quick check: Are you still planning to pursue this feature? The work here is substantial and impressive (23 review rounds, 121+ tests, thorough security hardening), and nothing equivalent has landed on main — so there's no duplication concern. But given the staleness and the volume of base-branch movement, I want to confirm you're still actively working on it before it drifts further. No rush — just let us know your plans so we can prioritize review accordingly. If you need any help with the rebase conflicts, happy to assist. |
What
Adds a Powers surface to the left rail's Apps group: browse the upstream registry, install a bundle, list what's installed, remove it.
This PR was split. It previously also contained activation (trust grant, enable/disable, MCP registration, generated docs skill). That half is deferred to a follow-up so this one is reviewable — see Why it was split below.
Installed Powers are inert — by construction, not by a flag
This is the security property the PR rests on, so it's stated as a claim a reviewer can check:
mcp.jsonis never parsed for specs — only tested for presence, to label the Powermcpvsknowledge. A declaredcommandhas no path to_set_kirocrew_entryor any other execution site.POWER.md/steering/*.mdstay insidepowers_dir(), and nothing else reads that directory —skills.py,context.py,agent.pycontain no reference to it. Third-party markdown cannot enter agent context.Consequently
installed.jsoncarries notrusted/enabledfield. A trust flag that gated nothing would assert a control the code doesn't implement.The UI says this plainly rather than leaving the absence unexplained: an Inactive badge plus a note that no MCP server is registered and no guidance is loaded. There's deliberately no greyed-out toggle, since that would imply an activation path that doesn't exist.
PowersTab.test.tsxasserts the absence of any switch or trust control, andTestInertInstallasserts no MCP config is written, noSKILL.mdexists anywhere, and the record has only{source, installedAt}.Why it was split
The combined PR wasn't converging under review: findings went 9 → 10 → 24 across three rounds, because each round's transactional hardening became the next round's finding surface. Nearly all of that complexity existed to make enable/disable/revoke safe — so it left with activation.
Reviewing activation separately also surfaced a real bug that would have shipped:
includeMcpJsonisfalse, so sessions read the rendered~/.kiro/agents/kirocrew.json, not<data home>/mcp.json. Every other MCP mutation path callsrebuild_agent_config()(handlers/mcp.py,mcp_custom.py) and uninstall additionally strips rendered entries, because the rebuild merges additively. The activation code did neither — so enable was a no-op and revoke left the server live. That bug is now moot rather than fixed: the redesigned activation (see Follow-up below) writes no MCP config at all, so there is nothing to rebuild. It is recorded because it is the reason the combined PR could not converge, anddocs/system-specs/modules/powers.mdkeeps it under Deferred: activation (pull model) as the constraint the new shape sidesteps.One review finding was resolved by deletion rather than hardening: the bash-side Powers write matcher (flagged as bypassable via
cd+ relative path,open(...,'w'), variable redirects) existed solely to stoptrusted: trueforgery. With no trust flag, it's gone. The file-edit gate protection stays, because.marketplace-cache.jsonstill decides which repository an id resolves to.Fixed while capturing screenshots: the registry never worked live
Re-shooting the browse screenshots against a real instance returned zero Powers, which turned out not to be a capture problem.
All three provider bodies were read with a single
await resp.content.read(cap + 1).StreamReader.read(n)returns whatever is buffered when it wakes and does not loop to filln, so every body larger than one wire chunk was silently truncated. Measured against the live upstreams:read(cap + 1)kiro.dev/powers/api.github.com/.../contents/The symptoms did not look like read bugs. The marketplace scrape parsed a partial document, found no cards, and marked itself unavailable; the official provider fed truncated JSON to
json.loadsand raisedProviderUnavailableError("Unterminated string starting at: line 1 column 5577"). Both surfaced as provider unavailable, so browsing the real registry rendered an empty catalogue. The per-file byte cap was equally unenforceable — a short read never reached it, so an oversized file was accepted as a truncated one.Fixed with a
read_cappedhelper that loops to the cap; callers keep their ownlen(body) > capcheck, so overflow policy (raise vs. truncate) stays at each call site.Why 95 tests missed it. Every existing test stubs
_http_get_json/_http_get_bytes/_fetch_html— the three functions that contain the read. The seam was mocked one level above the defect. The newTestBoundedStreamReadtherefore runs a loopback HTTP server, since a mocked stream can be made to return a whole body in one call, which is exactly the behaviour that does not hold on a socket. All five fail with the fix reverted, reproducing the production error text.Consequence: provider overlap became reachable
With the official provider working for the first time, the merge path matters. Dedup is by canonical repository URL and provider order decides ownership (official first) — but a dropped duplicate was taking its metadata with it. The official provider lists a GitHub directory, so
description/author/categoryare structurally empty for it, and the 26 of 82 overlapping Powers rendered authorless. The losing duplicate now donates any facet the winner left blank; populated fields are never overwritten.scopeis deliberately not merged, and the resulting divergence is documented rather than hidden: the providers disagree (monorepo membership vs. authorship facet), so an AWS-authored Power that is also mirrored into the official monorepo answers to the Official chip while its card showsAWSas the author.Publisher icons
Registry cards show the publisher icon the marketplace listing already exposes — present on 76 of 76 cards. Without it the browse grid was a table of names next to a directory that looks like a storefront.
The URL is scraped from a third-party page and ends up as an
imgsource, so its origin is an allowlist, not a coherence check, enforced at one chokepoint (marketplace.valid_icon_url):httpsonly, exactly the Kiro asset host, and a/powers/icons/path prefix. Rejected: scheme downgrade, foreign host, suffix-spoofed host (…kiro.dev.evil.com), path escape, andjavascript:. It is applied twice — when scraping and when reading the disk cache back — because the cache is on-disk state this spec already treats as attacker-reachable, and a poisoned cache entry would otherwise put an arbitrary origin in front of the user's session. A refused icon degrades the card; it never drops the Power. No CSP change was needed:img-srcalready allowshttps:.Icons are also what justifies the blank-facet merge above. The official provider lists a GitHub directory and has no icon to report, yet it wins the dedup for the 26 overlapping Powers — so without enrichment a third of the catalogue would render the fallback while the marketplace held a real icon for it. The client falls back to the Kiro Powers mark when the field is absent, and again on
onErrorwhen the host fails to serve the image, so the grid never shows broken-image glyphs.aws-mcpin the screenshot is a genuine fallback: it exists only in the official monorepo.The capture harness counts icons with
naturalWidth > 0, so a screenshot full of placeholders cannot pass as the feature working.Security controls retained
fetch.py— HTTPS-only host allowlist (github.com,api.github.com,raw.githubusercontent.com), nogit clone, traversal + symlink rejection, 8 MiB / 4 MiB / 200 files / depth-8 bounds enforced incrementally, bounded timeout, temp tree removed on every failure path including cancellation.POWER.md+mcp.json+steering/*.md. Vetting only the selected root isn't enough: an allowed ancestor (a home dir) can hold a validPOWER.mdand~/.ssh, and a recursive copy would relocate it under the powers dir. An allowlist makes that unrepresentable.resolve_install_sourcerefuses sensitive paths and audits the denial — the success-path SEL event wouldn't fire on a refusal, so a repeated probe of credential paths would otherwise leave no trace.powers/write-protected at the agent file-edit gate (reads still allowed — the dashboard renders it).await, so awaited compensation never runs; waiting synchronously stalls the loop), so five helpers that existed to manage that are deleted rather than tuned. Install re-parses the stagedPOWER.mdunder the lock because the source is caller-owned and mutable; remove destroys the staged bytes before dropping the record, so a failed delete can never orphan a bundle._root_lockopens the powers rootO_NOFOLLOW|O_DIRECTORYonce and every rename/delete goes through it, so a root swapped for a symlink mid-transaction cannot redirect a mutation outside the store. A validate-then-mutate check cannot promise that however tightly the two are placed._SUPPORTS_DIR_FDgates the POSIX path; Windows keeps the path check, where creating a symlink requires elevation.Screenshots
Powers in the Apps section of the left rail:
Browse the registry — real data, 82 entries fanned out across both providers (32 from the official
kirodotdev/powersmonorepo, 76 from the marketplace mirror, 26 overlapping and deduplicated by repository URL), with scope chips:Provider outage surfaces as stale-with-banner, not as an empty catalogue. Captured with the marketplace upstream made unresolvable and its disk cache aged past the TTL, so the provider takes the real transport-failure branch and serves expired entries while the official provider keeps working:
The Installed view — one MCP Power, one Knowledge Power, both showing the
Inactivebadge and the inert note, with no toggle or trust control anywhere:These are real: both Powers were installed through the actual
POST /api/powers/installfolder path on an isolated instance, and the API returnedkind=mcpfor the bundle shippingmcp.jsonandkind=knowledgefor the steering-only one — so the new presence-test labelling is exercised end-to-end, not only in unit tests. The capture harness assertsswitches === 0on the rendered page, making the inert claim a build-time check rather than something you have to take on trust.Verification
test_powers.py,test_powers_registry.py,test_powers_security_paths.py); 689 pass across thesecurity or powersselectionmypyclean,isortclean,flake8cleantscclean;PowersTab+PowersSurfacesuites greenPOWER.mdidentity check and the remove ordering each fail when the guard is removedFollow-up: activation (
feat/powers-activation) — redesigned around the IDE's modelNone of the following is in this diff. It is written down so the inert install reads as a deliberate stage rather than an omission.
This section previously described a push design: merge the Power's MCP servers into the rendered agent config, materialize its docs into a skill, and gate both behind a
trustedflag ininstalled.json. That design is withdrawn. Reading the IDE's own implementation (kiro-team/kiro-extension,src/extension/powers/**) shows upstream does neither — it exposes tools and lets the agent pull:listPowersmcpServersnames andkeywords, without starting anythingusePower(powerName, serverName, toolName, arguments)— namespaced dispatchreadPowerSteering(powerName, fileName)— one.md, on demand, path-validatedconfigurePowersENABLE_WEBUI = falseTheir steering rules are independently identical to ours:
.mdonly, no separators, no leading dot, validated join.What the pull model deletes from the plan
Four tools on the existing
kirocrew-coreMCP server (power_list,power_learn,power_use,power_steering) and nothing written to~/.kiro/agents/kirocrew.json. Each item below was a required deliverable of the push design and is now simply absent rather than solved:includeMcpJsonconstraint disappears. It dominated the old plan — sessions read the rendered config, so every writer had to callrebuild_agent_config()and uninstall had to strip rendered entries by hand. Nothing is written, so nothing must be rebuilt, and enable can no longer be a silent no-op.power-<power>-<server>namespace, so the injectivity problem (power-git-also prefixespower-git-helper-srv) and the ownership record a non-prefix purge needed both vanish. Power and server stay call arguments.trusted/enabledfields, soinstalled.jsonas shipped in this PR needs no migration. The "readers must treat a missing key as false" obligation this PR's spec previously imposed on the follow-up is withdrawn.Consent replaces the trust flag
The first
power_learn/power_usefor a Power in a session raises an approval naming the Power, the server, and the resolved command; per-session Trust auto-approves that Power for that session. This is the same conclusion PR #518 reached when its round-4 change deleted bespoke trust grants and routed through core approvals — a second bespoke trust store for Powers would repeat the mistake that change corrected. A durable "always allow" is deliberately out of scope for the first activation change.Where the risk moves
The push design spread risk across config merging, purge correctness and context injection. The pull design collapses it into one chokepoint — the process
power_usespawns — which must carry spec validation before spawn (no shell, no argv from tool arguments), environment discipline rather than ambient inheritance, per-call timeout plus the existing mandatory redactor on output, a SEL event per invocation, and a bash-layer write guard for the bundle'smcp.json. That last one carries over from the push design with a sharper rationale:mcp.jsonbecomes the source of an argv KiroCrew spawns, and it is read on every call rather than once at enable.One deliberate divergence from upstream
The IDE has no consent prompt — install is one click and the tools are callable. KiroCrew adds one because the IDE always has a human at the keyboard while KiroCrew runs turns from cron, Slack and Discord. This divergence is intentional and should not be "fixed" toward parity.
Layout divergences, recorded as choices
Upstream lives at
~/.kiro/powers(KIRO_POWERS_HOMEoverrides) with bundles underinstalled/<name>/and a versioned envelope —{version: "1.0.0", installedPowers: [{name, registryId, autoInstalled?}], dismissedAutoInstalls: [...]}— plusregistries/,registry-repos/andrepos/. This module uses our data home (~/.kiro/crew/powers), a name-keyed map with{kind, ref}provenance, and bundles at<name>/. Different trees, so no collision — and no interop: a Power installed through KiroCrew is invisible to the IDE. Acceptable while nothing is activated; first thing to revisit ifkiro-cliships a native runtime. TheirPowertype carriesiconUrl, which is the field this PR's cards use.Full design, including the tool table and the open questions, is in
docs/system-specs/modules/powers.mdunder Deferred: activation (pull model) and Upstream Powers implementation (observed, not inferred).Publisher icons
Registry cards show the publisher icon the marketplace listing already exposes — present on 76 of 76 cards. Without it the browse grid was a table of names next to a directory that looks like a storefront.
The URL is scraped from a third-party page and ends up as an
imgsource, so its origin is an allowlist, not a coherence check, enforced at one chokepoint (marketplace.valid_icon_url):httpsonly, exactly the Kiro asset host, and a/powers/icons/path prefix. Rejected: scheme downgrade, foreign host, suffix-spoofed host (…kiro.dev.evil.com), path escape, andjavascript:. It is applied twice — when scraping and when reading the disk cache back — because the cache is on-disk state this spec already treats as attacker-reachable, and a poisoned cache entry would otherwise put an arbitrary origin in front of the user's session. A refused icon degrades the card; it never drops the Power. No CSP change was needed:img-srcalready allowshttps:.Icons are also what justifies the blank-facet merge above. The official provider lists a GitHub directory and has no icon to report, yet it wins the dedup for the 26 overlapping Powers — so without enrichment a third of the catalogue would render the fallback while the marketplace held a real icon for it. The client falls back to the Kiro Powers mark when the field is absent, and again on
onErrorwhen the host fails to serve the image, so the grid never shows broken-image glyphs.aws-mcpin the screenshot is a genuine fallback: it exists only in the official monorepo.The capture harness counts icons with
naturalWidth > 0, so a screenshot full of placeholders cannot pass as the feature working.Security controls retained
fetch.py— HTTPS-only host allowlist (github.com,api.github.com,raw.githubusercontent.com), nogit clone, traversal + symlink rejection, 8 MiB / 4 MiB / 200 files / depth-8 bounds enforced incrementally, bounded timeout, temp tree removed on every failure path including cancellation.POWER.md+mcp.json+steering/*.md. Vetting only the selected root isn't enough: an allowed ancestor (a home dir) can hold a validPOWER.mdand~/.ssh, and a recursive copy would relocate it under the powers dir. An allowlist makes that unrepresentable.resolve_install_sourcerefuses sensitive paths and audits the denial — the success-path SEL event wouldn't fire on a refusal, so a repeated probe of credential paths would otherwise leave no trace.powers/write-protected at the agent file-edit gate (reads still allowed — the dashboard renders it).await, so awaited compensation never runs; waiting synchronously stalls the loop), so five helpers that existed to manage that are deleted rather than tuned. Install re-parses the stagedPOWER.mdunder the lock because the source is caller-owned and mutable; remove destroys the staged bytes before dropping the record, so a failed delete can never orphan a bundle._root_lockopens the powers rootO_NOFOLLOW|O_DIRECTORYonce and every rename/delete goes through it, so a root swapped for a symlink mid-transaction cannot redirect a mutation outside the store. A validate-then-mutate check cannot promise that however tightly the two are placed._SUPPORTS_DIR_FDgates the POSIX path; Windows keeps the path check, where creating a symlink requires elevation.Screenshots
Powers in the Apps section of the left rail:
Browse the registry — real data, 82 entries fanned out across both providers (32 from the official
kirodotdev/powersmonorepo, 76 from the marketplace mirror, 26 overlapping and deduplicated by repository URL), with scope chips:Provider outage surfaces as stale-with-banner, not as an empty catalogue. Captured with the marketplace upstream made unresolvable and its disk cache aged past the TTL, so the provider takes the real transport-failure branch and serves expired entries while the official provider keeps working:
The Installed view — one MCP Power, one Knowledge Power, both showing the
Inactivebadge and the inert note, with no toggle or trust control anywhere:These are real: both Powers were installed through the actual
POST /api/powers/installfolder path on an isolated instance, and the API returnedkind=mcpfor the bundle shippingmcp.jsonandkind=knowledgefor the steering-only one — so the new presence-test labelling is exercised end-to-end, not only in unit tests. The capture harness assertsswitches === 0on the rendered page, making the inert claim a build-time check rather than something you have to take on trust.Verification
test_powers.py,test_powers_registry.py,test_powers_security_paths.py); 689 pass across thesecurity or powersselectionmypyclean,isortclean,flake8cleantscclean;PowersTab+PowersSurfacesuites greenPOWER.mdidentity check and the remove ordering each fail when the guard is removedFollow-up: activation (
feat/powers-activation)None of the following is in this diff. It is written down so the inert install reads as a deliberate stage rather than an omission, and so a reviewer can judge whether the seams this PR leaves are the right ones. The engineering constraints are recorded in
docs/system-specs/modules/powers.mdunder Deferred: activation; what follows adds the product shape and separates what is decided from what is not.Must ship together
Activation is not a flag, because five things are only correct in combination:
SkillsLoaderkeyword triggers, treated as security-relevant in this repo). Gating only MCP would make "Disabled" untrue for a Knowledge Power.power-<power>-<server>namespacing with an ownership-exact purge. Both segments admit hyphens, so prefix matching is unsound —power-git-also matchespower-git-helper-srv, owned bygit-helper.installed.jsongains a record of the exact entries each Power registered.includeMcpJsonisfalse, so writing only<data home>/mcp.jsonis a no-op on enable and leaves a revoked server live (detail in Why it was split).power_trust_grant/power_trust_revoke,power_enable/power_disable.powers_dir(). While install is inert the file-edit gate is proportionate; once a trust flag is authoritative and bundle contents are executable, a bash-side matcher becomes load-bearing.Proposed user-facing flow
Installed-and-Inactive stays the resting state after install — activation is always a second, explicit act.
Open questions, not yet decided
Migration
Records written by this PR carry only
{source, installedAt}. Activation's readers must treat a missingtrusted/enabled/mcpServerskey asfalse/empty, so anything installed by this version keeps loading and lands as Inactive — upgrading never confers an implicit grant.