* feat: resolve @prompt_name saved-prompt references in workflow step prompts
Workflow step prompts, the GitHub review-watch task prompt, and the CI
auto-fix prompt now resolve "@name" references to saved custom prompts
(Settings > Prompts) at invocation time, appending the referenced
content as hidden context while keeping the mention visible - matching
how chat messages and message_task already resolve @mentions. Editing
a saved prompt now propagates to every workflow step that references
it without needing to update each step individually.
Adds a Monaco "@" completion provider to the step-prompt and
review-watch editors, alongside the existing "{{placeholder}}"
provider.
* fix: address PR review findings on prompt-reference expansion
- Make AppendReferenceExpansions idempotent: a prompt that already
contains a previously-appended expansion block is returned unchanged,
since the block's own "### @name" headers would otherwise be
re-scanned as fresh mentions on a second call.
- Skip "@name" expansion entirely for CLI-passthrough sessions: their
prompts are written raw to a PTY with no <kandev-system> stripping,
so the hidden block would otherwise leak into the terminal verbatim.
* fix: fail safe on passthrough lookup error in startTask
A session-lookup error while resolving isPassthrough for the Kandev
MCP wrap and prompt-reference expansion previously defaulted to
false (non-passthrough), risking a hidden <kandev-system> block
leaking into a real passthrough session's PTY on a transient error.
Extracted resolveIsPassthroughForLaunch, which now fails safe by
defaulting to true (skip injection) and logs a warning.
* fix: scope expansion idempotency marker to system blocks
The idempotency guard added in a prior commit checked for the
expansion marker anywhere in the prompt, which could falsely
suppress a legitimate first-time expansion if caller-controlled
text happened to contain that literal phrase. Add
sysprompt.HasSystemMarker, mirroring the existing HasKandevContext
pattern, so the marker is only recognized inside a formatter-
produced <kandev-system> block. Also strengthens a passthrough test
assertion that didn't actually distinguish the snapshot path from
the fail-safe default.
* fix: match exact expansion-block prefix in idempotency guard
The prior scoping fix accepted the marker anywhere inside any
<kandev-system> block, which could still false-positive against an
unrelated system block (e.g. a plan-mode prefix or MCP context
wrap) that happens to mention the marker phrase without it being
the block's own content. Check for the exact literal prefix this
package's formatter produces instead: the opening tag immediately
followed by the marker, with nothing in between.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* fix(executor): re-clone provider repos when local path is missing or not a git repo
Previously the re-clone guard only fired when local_path was empty, so a
set-but-invalid path (e.g. deleted checkout, or relocated by the provider
clone-path/identity change) was fed straight to the worktree preparer,
which hard-failed with "repository is not a git repository" instead of
recovering.
Two-part fix:
- resolveTaskRepoInfo now also re-clones when local_path exists but isn't
a valid git checkout (new isLocalGitRepo helper), self-healing on next
launch.
- validateWorktreeRequest now fails the "Validate repository" step with an
honest error naming the offending path when it truly isn't a git repo,
instead of passing and cascading into a misleading "sync interrupted"
failure downstream.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(executor): gate stale-path reclone to provider repos and cover resume + multi-repo
Restrict the stale-local-path re-clone to genuinely provider-backed repos
(SourceType != "local") so a local checkout that's temporarily missing is
never silently overwritten. Centralize the re-clone guard into a shared
ensureRepoLocalPath helper and call it from the single-repo resume path
(applyResumeRepoConfig) so resume self-heals like a fresh launch. Apply the
same non-git-path validation used by the single-repo prepare path to the
multi-repo prepareOneRepo path so a stale secondary repo fails fast with a
clear message instead of surfacing worktree.Manager's ErrRepoNotGit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(web): add app status bar
* feat(web): add app status bar visibility toggle
* feat(web): persist status bar item order
* fix(web): refine app status bar behavior
* fix(web): polish app status bar presentation
* test(web): split plugin slot coverage groups
* fix(web): address status surface review feedback
* fix(web): stabilize status bar overlay tests
* fix(plugins): unload previous version before reloading on plugin update
The install/update path reloaded a plugin without first unloading the
previous version's registrations, unlike disable/uninstall. Because the
slot registry only appends, this ran initialize() twice and left two
main-top-bar slot entries, duplicating the plugin's widget icon until
a page refresh.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): cache-bust plugin bundle import so updates re-execute
unloadPlugin(id, {evictCache: true}) drops the cached bundle registration
on install/update, but a same-tab re-import() of the identical bundle URL
returns the browser's already-evaluated ES module without re-running its
top-level registerKandevPlugin() call — leaving an updated plugin active
but missing from the UI. Key the bundle URL on the plugin's installed
version (?v=<version>) on both the frontend (toActivePlugin) and the
backend boot payload (bootActivePlugins) so a version bump forces a real
re-import while an unchanged version stays cache-friendly across boots.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(plugins): update bundleUrl assertions for cache-bust query param
toActivePlugin now appends a ?v=<version> cache-busting query param to
bundleUrl; these two assertions still expected the bare path.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): only evict bundle cache on install/update, not on enable
loadIfActive unconditionally evicted the cached bundle registration, which
broke the disable->enable cycle: since bundleUrl doesn't change on plain
enable, the browser's ES module cache returned the already-evaluated module
without re-running registerKandevPlugin, leaving the plugin's slots/routes
unregistered until a full page refresh.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(plugins): add capability-gated Host reader for conversation content
Plugins can now read historical user/agent message content over the Host
data API, gated by a new `api_read:messages` capability (ADR 0047). This
unblocks "summarize yesterday"-style plugins that the live `message.added`
bus event alone cannot serve.
- proto: `ListMessages` RPC + `Message`/`MessageFilter` on `service Host`.
- SDK: `Host.Messages() MessageReader`, Go-native `Message`/`MessageFilter`.
- host: `api_read:messages` accessor + denied reader; content sanitized via
`sysprompt.StripSystemContent` (no raw system prompts); RFC3339 `since`/
`until` → `InvalidArgument` on bad input.
- data: new `ListMessagesForPlugin` filtered query (session/task ids, type,
created_at range) through the task service, never the DB file.
- tests: in-process + wire capability gating, content stripping, filter/time
passthrough, pagination, and the SQLite query.
- docs: ADR 0047, spec, plugins-manifest, plugins-authoring.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(plugins): add Host.InvokeUtilityAgent for settings-selectable utility agent
Plugins can now delegate a one-shot LLM step to an operator-configured agent,
gated by a new `agent_invoke` capability (ADR 0048), so a plugin needs no
provider API key of its own.
- setting: new `utility_agent_profile_id` user setting (Settings > System >
Utility Agent), a profile dropdown cloned from the existing utility-agent
path; selectable in the UI.
- capability: boolean `agent_invoke`, enforced like `state`/`secrets`.
- proto/SDK: `InvokeUtilityAgent(prompt) -> text` RPC and
`Host.InvokeUtilityAgent(ctx, prompt)`.
- backend: reuses the sessionless host-utility inference tier (ADR 0002) via a
thin adapter — no agent loop, no task/session/workspace; resolves the
configured profile to (agent type, model). Unconfigured / deleted profile ->
gRPC FailedPrecondition.
- tests: capability gating, completion path (asserts the configured profile is
used and text returned), no-utility-agent and stale-profile error paths,
SDK/wire round-trips, and the settings apply/trim behavior.
- docs: ADR 0048, spec, plugins-manifest, plugins-authoring.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): resolve utility-agent deps live and bound message filter size
Address CI + review feedback on the host plugin capabilities:
- docs: register the new /settings/system/utility-agent route in
docs/public/coverage.json (fixes the "Validate public docs" CI failure).
- utility agent (ADR 0048): hostUtilityMgr is wired late in boot — after
StartActivePlugins has already spawned boot-active plugins — so snapshotting
the utility deps in hostForPlugin stranded those hosts with nil deps and made
InvokeUtilityAgent return Unimplemented for their lifetime. Read the deps live
from the Service (mutex-guarded) at call time instead; add a regression test
for late wiring reaching an already-spawned host.
- messages (ADR 0047): cap the combined session/task/type filter values at 400
so an oversized filter fails fast with InvalidArgument instead of hitting
SQLite's host-parameter limit at query execution; add a test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(backend): reject disallowed cross-origin state changes in CORS
The CORS middleware only aborted disallowed origins on the OPTIONS
preflight, then called c.Next() for every other method. A CORS-safelisted
request (e.g. a multipart/form-data POST, which skips preflight) from a
malicious page therefore still executed the handler's side effect even
though the browser could not read the response.
Against a loopback-bound backend this is a drive-by CSRF primitive: any
web page the victim visits could POST to a mutating endpoint such as
/api/plugins/install, which unpacks and spawns the uploaded plugin binary
(RCE), with no authentication layer in front of it.
Now a disallowed cross-origin request is rejected with 403 for the
preflight and every state-changing method; only CORS-safe methods
(GET/HEAD) fall through without CORS headers, so the browser still cannot
read the response and no state is changed. No-Origin (CLI/curl) and
allowed same/loopback-origin requests are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(backend): block all disallowed cross-origin requests in CORS
Addresses review: GET is not universally side-effect-free here. Plugin
webhooks are registered for GET as well as POST
(/api/plugins/:id/webhooks/:key -> Service.InvokeWebhook), so the earlier
GET/HEAD whitelist still let a disallowed-origin browser GET trigger that
unauthenticated side effect via drive-by CSRF.
Reject every method (including GET/HEAD) with 403 for a disallowed origin.
This loses nothing legitimate: a response the browser could read
cross-origin needs CORS headers we only emit for allowed origins.
Non-browser clients (CLI/curl) and resource loads (<img>/<script>) send no
Origin and take the wildcard branch, so they are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(plugins): add main-top-bar slot for the default app top bar
Plugins could only contribute to the session/chat top bar via the
chat-top-bar slot. Add a main-top-bar slot, mounted on the default
app top bar (Home / Kanban / Tasks) across desktop, tablet, and
mobile headers, so plugins can surface workspace-scoped status or
actions outside of a task session.
slotProps: { workspaceId, workspaceLabel, currentPage } — no task or
session ids, since the bar isn't scoped to a task.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: narrow main-top-bar currentPage slot prop to a literal union
Addresses Claude Code Review feedback on PR #1841: MainTopBarSlotProps.currentPage
was typed as a plain string even though every caller only ever passes
"kanban" | "tasks" (matching KanbanHeaderProps.currentPage). Narrow the
plugin-facing contract to match so plugin authors get real type safety.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* test(e2e): cover the main-top-bar plugin slot
Extend the kandev-plugin-e2e fixture bundle to also register a
main-top-bar slot component, and assert it renders on the default
app top bar (Home) alongside the existing task-sidebar coverage.
Verified locally: apps/web e2e plugins.spec.ts passes, plugin-fixture
Go tests pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* feat(plugins): show source repo link on installed plugin list and settings
Installed plugins carried no repository URL — a "Repo" link existed only
on marketplace catalog cards, whose repo_url is registry-derived from
plugins.yaml and never reaches an installed record. Let the manifest
declare an optional repo_url so sideloaded and directly-installed plugins
surface the same link.
- manifest: add optional RepoURL (repo_url); validate http(s)-only at
registration so a javascript: scheme can't reach the operator's session.
- Record embeds the manifest, so repo_url flows through JSON to the
frontend PluginRecord with no extra plumbing.
- frontend: extract the shared, guarded PluginRepoLink (+ isHttpUrl) from
the marketplace row and render it on the installed plugin row and the
plugin detail header.
- fixture + docs: exercise repo_url in the e2e fixture manifest and
document the field in the public manifest reference.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): harden repo_url handling per review
- PluginRepoLink accepts string|undefined|null and isHttpUrl null-checks
before trim, so it can't throw on an absent repo_url (CodeRabbit); drop
the now-redundant truthiness guards at the call sites.
- validateRepoURL trims m.RepoURL in place so the stored/serialised value
matches what was validated (Greptile).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): serialize empty sync-result arrays instead of null
Sync's result slices (Added/Installed/Missing/Errors) stayed Go nil when
nothing changed, and encoding/json marshals a nil slice as null. The
frontend's SyncResult type declares these as non-nullable arrays and reads
result.added.length unconditionally, so a routine "everything up to date"
sync crashed with "can't access property 'length', e.added is null".
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* refactor(plugins): initialize sync-result slices at construction
Per review feedback (Claude, Greptile): normalizing nil slices at each
return site in sync() left the invariant fragile — a future early return
would silently reintroduce the null-serialization bug. Initialize
SyncResult's slices non-nil at the struct literal instead, and append the
scan results onto them.
Also tighten the regression test to check exact JSON field values instead
of a raw strings.Contains("null") substring search.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
* fix: harden agentctl bind, git clone args, and powershell sound escaping
Three LOW-severity defense-in-depth hardening fixes with PoC + regression tests:
- notifications: escapePowerShell now neutralizes backtick and `$` in addition
to `"`, defusing `$(...)` sub-expression and `$var` injection through an
operator-supplied SoundFile path interpolated into the powershell `-c` script.
- office/skills: git clone for git-sourced skills now passes a `--`
end-of-options separator before the source locator (matching configloader),
so a `-`-prefixed locator can never be parsed as a git flag.
- agentctl: new Config.ListenHost() binds the control server and per-instance
listeners to loopback only when no AuthToken is configured (auth disabled),
so the unauthenticated command/shell/process routes are never reachable
beyond localhost. The normal nonce-handshake flow (incl. Docker) always has
a token and keeps binding all interfaces.
* test: make PoC tests flip and drop indexOf coupling
Address review feedback on the security-hardening PoCs:
- system_provider PoC now demonstrates the PowerShell sub-expression injection
against a legacyEscapePowerShell copy of the unfixed function and asserts the
current escaper defuses it, so the PoC genuinely flips instead of passing
trivially against fixed code (a substring match on `$(` matched the escaped
form too).
- materialization PoC + regression test use stdlib slices.Index instead of a
custom indexOf defined in the PoC file, removing the coupling where deleting
the PoC file would break the regression test.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* feat(plugins): add plugin marketplace with curated registry and updates
Discover and install plugins from inside kandev instead of only by URL/upload.
- Backend marketplace subpackage: SQLite-backed sources, catalog fetch/merge
(dedup, install_state, degraded-source handling), and the /api/plugins/
marketplace[/sources] API. Install reuses the existing install-by-URL pipeline.
- Frontend: Settings > Plugins gains a Browse tab (search/category/star sort),
source management, one-click install, and an Update button on the Installed
tab when the catalog has a newer version. Plugins may ship an icon (manifest
`icon` -> catalog icon_url) with a letter-tile fallback.
- Registry infra: plugin-registry/ (curated plugins.yaml + schema.json) and two
GitHub Actions (zero-dependency Node build-index.mjs -> index.json on Pages,
daily star refresh). Ships empty at launch; the empty catalog is valid.
- Spec: docs/specs/plugins/marketplace.md. No download telemetry; stars only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): address marketplace PR review feedback
- CodeQL: stub fetch routers keyed off a path segment, not a hostname substring.
- Nullable stars end to end: a failed star lookup stays unknown (not 0) and
sorts last, instead of decoding null -> 0 and corrupting ranking.
- Fetch marketplace sources concurrently and dedupe identical in-flight
downloads (singleflight), so a slow source can't serialize the Browse tab.
- Register /marketplace routes before the /:id wildcard (gin tree ordering).
- Duplicate source URL returns 409 ErrDuplicateSource, not a raw SQL error.
- Browse: filter category client-side so the category dropdown never collapses.
- marketplaceInstall swallows after toasting (no unhandled promise rejection).
- Source badge keys off source_id, not a name substring.
- build-index: read block-sequence manifest categories, not just inline.
- Registry index workflow validates on pull_request; Pages deploy gated to
push/schedule/dispatch. Spec: align package_sha256 (advisory in v1) + sort enum.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): guard catalog repo_url against javascript: href injection
repo_url comes from an untrusted index.json, so a malicious/compromised source
could set a javascript: URL that runs in the operator's session when the Repo
link is clicked (rel/target don't block it). Only render the anchor for http(s)
schemes. Adds regression tests for the scheme guard and null-star rendering.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): require https for remote sources; test oversized download
- Reject remote plain-http marketplace sources (MITM catalog-poisoning risk);
loopback http stays allowed for local dev/e2e fixtures.
- Add the missing oversized-response assertion to TestDownloadRejectsNon200AndOversized
so the maxIndexBytes memory bound has coverage.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(plugins): detach source download from singleflight leader's context
Two concurrent Browse opens after a cache Refresh share one singleflight
download; if the leader's request context is canceled (it navigates away), the
waiters must not inherit that cancellation. Use context.WithoutCancel so values
are preserved but the leader can't cancel everyone; the client's 20s timeout
still bounds the download.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: harden submodule init against malicious .gitmodules
initSubmodules ran a bare `git submodule update --init --recursive` in a
worktree whose HEAD is attacker-controlled during a fork-PR review task,
with no protocol restriction, no hooks disable, and no cmd.Env. Attacker
`.gitmodules` could pick a command-executing transport (`ext::sh -c`,
CVE-2018-17456), a local `file://` path, or a plain-http URL (cloud
metadata SSRF).
Harden the single sink so all call sites benefit:
- protocol.allow=never default-denies every transport; only https/ssh are
re-enabled (blocks file://, git://, plain http:// SSRF).
- protocol.ext.allow=never hard-pins the RCE transport off; on the command
line it outranks any ambient protocol.ext.allow=always.
- core.hooksPath=/dev/null neutralizes attacker-planted hooks.
- GIT_TERMINAL_PROMPT=0 stops attacker URLs from hanging on a prompt.
Residual: attacker-chosen https/ssh URLs are still fetched (transports real
submodules require); fully closing that needs gating submodule init off for
untrusted worktrees, tracked separately.
* fix: address submodule hardening review feedback
- Reuse newNonInteractiveGitCmd so submodule init inherits the full
non-interactive env set (GIT_SSH_COMMAND=ssh -oBatchMode=yes, SSH/GCM
askpass suppression, WaitDelay). Prevents an attacker ssh:// submodule
URL from hanging the review runner on host-key/passphrase prompts until
the context deadline (GIT_TERMINAL_PROMPT=0 alone only covers git's HTTP
prompt, not SSH).
- Correct the file:// comment: command-line -c outranks repo/global git
config, so ambient protocol.file.allow=always does NOT re-enable file
submodules here. This is intentional for untrusted trees; a trusted
opt-in must be an explicit per-repo -c, not implicit host config.
- Add a file:// URL variant to the transport-block test alongside the
bare-path form (CVE-2022-39253 classification differs by git version).
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>