Commit Graph

1008 Commits

Author SHA1 Message Date
José Almeida 9c16d337a2 feat(web): add global app status surface (#1869)
* 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
2026-07-23 12:28:54 +01:00
José Almeida 7de788ceed feat: add cross-integration entity references (#1862)
* feat: add cross-integration entity references

* fix(web): anchor entity reference menu to composer

* fix: keep Kandev tasks under @ mentions

* fix: address entity reference review findings

* test(e2e): use option role for prompt menu

* test(e2e): update passthrough mention roles

* fix: preserve queued references and paginate Azure PRs
2026-07-23 09:03:56 +01:00
João Salavisa 07038018ac fix(plugins): unload previous version before reloading on plugin update (#1861)
* 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>
2026-07-22 13:45:18 +01:00
José Almeida 24f2fba183 fix: restore line expansion for multi-repo diffs (#1856)
* fix: restore line expansion for multi-repo diffs

* test: clarify review file grouping

* fix: require repository scope for ref reads
2026-07-22 09:10:54 +01:00
João Salavisa 6d5fc4794b fix(plugins): select utility agents per plugin (#1855)
* fix(plugins): select utility agents per plugin

* fix(plugins): address utility agent review feedback

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-07-22 01:50:40 +01:00
João Salavisa 78cae96bb1 feat(plugins): host-side conversation reads + utility-agent invoke (#1852)
* 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>
2026-07-22 00:20:09 +01:00
João Salavisa c4a856dc10 fix(backend): reject disallowed cross-origin state changes in CORS (#1850)
* 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>
2026-07-21 23:55:13 +01:00
Carlos Florêncio 7fd99c3f33 feat: complete GitLab integration parity (#1832) 2026-07-21 23:24:22 +01:00
Carlos Florêncio 5499eadf19 fix(github): keep draft pull requests out of merge-ready state (#1851) 2026-07-21 23:22:49 +01:00
José Almeida c645885cb5 fix(tasks): preserve inherited workspaces on subtask deletion (#1840)
* fix(tasks): preserve inherited workspaces on subtask deletion

* fix(tasks): avoid archive workspace ownership transfer

* fix(tasks): serialize shared environment handoff

* test(tasks): cover blocked shared environment handoff

* test(tasks): cover last-member environment cleanup

* fix(tasks): roll back shared environment ownership
2026-07-21 22:37:02 +01:00
Alassane 21b0003b32 fix: surface orphaned tasks in pipeline view after step deletion (#1809) 2026-07-21 21:37:50 +01:00
Carlos Florêncio be56eff1a2 fix: reset office session state when execution profile changes (#1846) 2026-07-21 21:37:08 +01:00
João Salavisa 330afb7901 feat(plugins): add main-top-bar slot for the default app top bar (#1841)
* 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>
2026-07-21 17:18:34 +01:00
Carlos Florêncio c2a678b023 feat: add Azure DevOps integration (#1778) 2026-07-21 08:40:26 +01:00
Carlos Florêncio d7c24184cd fix(agent): refresh opencode model cache (#1830) 2026-07-21 08:39:42 +01:00
Carlos Florêncio 2130c28686 fix(repositories): allow explicit paths outside discovery roots (#1825) 2026-07-20 19:49:41 +01:00
José Almeida 3479fb601e fix(web): refresh walkthrough ranges on Monaco model changes (#1822)
* fix(web): refresh walkthrough ranges on Monaco model changes

* fix(web): avoid recentering walkthrough on line edits

* test(backend): isolate executor session snapshots

* test(backend): deep-copy executor session snapshots
2026-07-20 17:34:15 +01:00
João Salavisa 91bdee3d3a feat(plugins): show source repo link on installed plugin list and settings (#1821)
* 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>
2026-07-20 14:03:52 +01:00
Carlos Florêncio 77529e1eab fix: preserve shared worktrees during task cleanup (#1819) 2026-07-20 13:53:48 +01:00
João Salavisa 18ba644388 fix(plugins): serialize empty sync-result arrays instead of null (#1817)
* 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>
2026-07-20 12:50:18 +01:00
Carlos Florêncio ba75922fa1 feat: add configurable task layout profiles (#1799) 2026-07-20 12:09:06 +01:00
João Salavisa 4d1787f484 fix: harden agentctl bind, git clone args, and powershell sound escaping (#1811)
* 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>
2026-07-20 11:32:43 +01:00
Carlos Florêncio f212258907 perf: scale workspace git status for large worktrees (#1814) 2026-07-20 11:04:59 +01:00
Carlos Florêncio 110c2efcb1 fix: report storage usage and reclaim archived workspaces (#1813) 2026-07-20 09:35:57 +01:00
Carlos Florêncio 33aa7af67e feat: configure profiles for MCP-created tasks (#1797) 2026-07-19 23:18:06 +01:00
Carlos Florêncio 84a9ca0822 fix: ignore echoed user ACP chunks (#1804) 2026-07-19 22:09:49 +01:00
Carlos Florêncio 00f8c6b202 fix: repair incomplete code-server installations (#1800) 2026-07-19 22:09:40 +01:00
José Almeida f7f1d10996 feat: add parent-controlled task stopping via MCP (#1803)
* feat: add parent-controlled task stopping via MCP

* fix: harden parent task stop lifecycle

* fix: preserve rejected task rollback state
2026-07-19 20:23:46 +01:00
João Salavisa bc2f310e73 feat(plugins): add plugin marketplace with curated registry and updates (#1798)
* 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>
2026-07-19 17:10:53 +01:00
João Salavisa ac8401b709 fix: harden submodule init against malicious .gitmodules (#1802)
* 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>
2026-07-19 16:12:56 +01:00
João Salavisa 19c3771cf0 fix: validate destination parent chain in copyfiles byte copy (#1801)
* fix: validate destination parent chain in copyfiles byte copy

copyState.copyFile wrote into the target worktree via os.MkdirAll +
os.Create with no destination-parent symlink validation, unlike the
sibling paths that were hardened for exactly this: symlinkMatch calls
validateParentChain and remote writeOneEntry calls secureDestination.

A worktree whose HEAD ships a directory segment as a symlink (a fork-PR
head can set a tree entry to mode 120000 pointing at an absolute host
path) let MkdirAll follow the link and os.Create write the source file's
bytes outside the worktree as the backend user — a zero-click,
content-driven arbitrary-file-write reachable on the PR-review path
(copyConfiguredFiles -> copyfiles.Copy during worktree creation).

Route the byte copy through the same secureDestination hardening: it
runs validateParentChain, creates the parent dirs, and re-confirms
containment via EvalSymlinks before returning a destination guaranteed
inside the worktree. A rejected destination emits a non-fatal warning,
mirroring symlinkMatch. The leaf os.Lstat skip-if-exists behavior is
preserved (only a symlinked parent was the escape vector).

Add byte-copy regression tests: a symlinked parent is rejected and
nothing is written outside the worktree, a leaf symlink is still
skipped, and a clean nested copy still works.

* fix: canonicalize copyfiles target root before containment checks

copyFile passed the raw s.targetDir into secureDestination, which
EvalSymlinks-resolves the created parent and then Rel-checks it against
that root. When the worktree is reached through a symlinked ancestor
(macOS /tmp -> /private/tmp, or a user-symlinked tasks dir) the canonical
parent looks like it escapes the non-canonical root, so every legitimate
byte copy is silently rejected.

Resolve targetDir once in Copy (falling back to the raw path when it does
not exist yet), mirroring the EvalSymlinks WriteEntries already performs.
Add a regression test that routes the target through a symlinked ancestor
and asserts the copy still lands at the real location.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-07-19 16:12:44 +01:00
José Almeida 1a42c07abe fix: keep queued messages parked after agent cancellation (#1796) 2026-07-19 13:50:03 +01:00
Carlos Florêncio 4fdd679269 fix: attribute model configuration to turns (#1784) 2026-07-19 12:46:40 +01:00
Carlos Florêncio 1dc96a92c6 fix: restore multi-repo review diffs (#1795) 2026-07-19 12:25:36 +01:00
Carlos Florêncio b5dc6a261e fix: hide workspace ownership marker from git status (#1783) 2026-07-19 11:11:26 +01:00
João Salavisa cdd0bf8cbd feat: bind HTTP server to multiple addresses (#1792)
* feat: bind HTTP server to multiple addresses

Support a list of bind addresses so the backend can serve on loopback and
a tailnet IP at once, without exposing every interface via 0.0.0.0.

- Config: server.host now parses a comma-separated list; a new server.hosts
  YAML array takes precedence when set. Entries are trimmed, deduped, and
  validated (IP or hostname); a wildcard entry collapses the set.
- Startup: one net.Listener per resolved address serving one shared
  http.Server; the single-listener path is the one-element case.
- Bind-failure policy: all-fail is fatal; partial-fail warns, serves the
  good addresses, and runs a cancellable background retry loop that binds
  late-arriving addresses (e.g. a tailnet IP after tailscaled starts).
- Graceful shutdown stops the retry loop before closing all listeners;
  covered by goleak.
- Readiness probe picks a reachable bound address, preferring loopback.
- Add ServerConfig.NonLoopbackBinds and config.IsLoopbackHost so the
  sibling app-auth task can gate fail-closed auth on off-box binds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: preserve KANDEV_SERVER_HOST over config server.hosts

server.host now wins over the server.hosts array when set, because Host
carries the KANDEV_SERVER_HOST env override and env must beat a config-file
server.hosts (env-over-file precedence and the desktop loopback contract).
The viper default for server.host is empty so an unset host is
distinguishable from an explicit one; server.hosts is used only when host is
unset. Adds regression tests covering the env-override and hosts-when-unset
paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: harden multi-bind host parsing and listener lifecycle

Address review feedback:
- Validate every host before wildcard collapse so an invalid entry is
  rejected regardless of order; canonicalize IPs so equivalent forms
  (::1 and 0:0:0:0:0:0:0:1) dedupe and unspecified longhand IPs count as
  the wildcard.
- Probe the IPv6 loopback (::1) for an IPv6 wildcard listener instead of
  127.0.0.1, so readiness doesn't stall on an IPv6-only socket.
- Make serverListeners.Stop race-free via sync.Once.
- Register t.Cleanup for the blocker listener before the fatal-assertion
  path in the partial-failure 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>
2026-07-19 10:36:44 +01:00
João Salavisa 8f23b8a19e fix: prevent command injection via untrusted git branch names (#1791)
* fix: prevent command injection via untrusted git branch names

Untrusted git branch names (e.g. a fork PR head branch) were substituted
unescaped into executor prepare scripts and run via `eval` in a shell,
yielding command injection (CRITICAL RCE) in the Docker/Sprites/remote
executors. git check-ref-format permits `;`, `|`, `$`, backticks and
`()`, so a branch like `$(touch pwned)` or `main;touch pwned` executed.

- scriptengine: shell-single-quote every data placeholder that lands in
  shell text (repository.path/name/branch/clone_url/ssh_url,
  workspace.path, worktree.base_path/path/branch/base_branch). Script
  fragments (setup_script, git.identity_setup, github.auth_setup,
  kandev.*) are left untouched.
- default_scripts: wrap each data placeholder in single quotes in the
  postlude, clone lines, and the Sprites echo (double quotes still allow
  $(...) substitution). `'<escaped>'` round-trips to the literal.
- orchestrator: defense-in-depth — reject a PR head branch that fails
  securityutil.IsValidBranchName before it reaches CheckoutBranch, while
  still creating the review task without a checkout.
- tests: PoC-derived regression asserting the injected command no longer
  runs through the real sh -c/eval sink, provider escaping unit tests,
  and ingestion-layer validation coverage.

* fix: harden branch-name escaping for legacy stored prepare scripts

Automated review (Codex, CodeRabbit) flagged that shell-single-quoting the
value while relying on the templates to add the surrounding quotes only
protects the templates we ship. Profiles snapshot the default prepare
script at creation time, so an OLD stored script referencing a placeholder
bare (e.g. `git clone --depth=1 --branch {{repository.branch}} ...`) still
resolved with an unquoted, injectable branch value.

- scriptengine: add shellQuote, which emits a fully self-contained
  single-quoted shell token (surrounding quotes included). Every data
  placeholder (repository.path/name/branch/clone_url/ssh_url,
  workspace.path, worktree.base_path/path/branch/base_branch) now carries
  its own quoting, so a bare `{{placeholder}}` in any template — including
  legacy stored scripts — is safe. shellSingleQuote is kept for the git
  identity fragment, which supplies its own quotes.
- default_scripts: reference data placeholders bare (the value self-quotes);
  the Sprites echo becomes printf with the values as separate args so a
  hostile URL/branch can't expand inside a double-quoted string.
- tests: assert providers emit fully-quoted tokens that round-trip through a
  real shell; cover base_path/path/repository.path/name/ssh_url; add a
  regression proving a legacy stored script with bare placeholders no longer
  executes injected commands.

* test: strengthen branch-injection regression assertions

Address Greptile review feedback on the new tests.

- assertNoUnquotedPayload now tracks real single-quote state char-by-char
  instead of "some quote before and after", so a payload in an unquoted
  context on a line that happens to contain other quotes is caught.
- Drop the unused wantHasRepoRow table field; repo-row presence is already
  asserted via len(got) != 1.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-07-19 10:33:29 +01:00
João Salavisa 410b1dd4d1 fix: prevent AppleScript injection RCE in macOS notifications (#1790)
* fix: prevent AppleScript injection RCE in macOS notifications

buildAppleScript escaped only the double-quote in notification title/body
and interpolated the result into an `osascript -e` string literal. A raw
trailing backslash defeated that escaping: `\"` became `\\"`, which
AppleScript reads as an escaped backslash plus a real closing delimiter,
so the `display notification "..."` literal terminated early and any
following text (e.g. `do shell script`) executed on the host.

Notification title/body come verbatim from untrusted external-integration
issue titles (Sentry/Linear/Jira) with no sanitization, so an attacker who
can raise an issue in a connected workspace could achieve RCE on the macOS
host when the office session reached WAITING_FOR_INPUT.

Replace string interpolation with argv passing: title/body are delivered as
`run` handler arguments (item 1/2 of argv) and never touch the AppleScript
source, so no escaping is required. Add a proof-of-concept test documenting
the legacy breakout and a regression guard asserting the fixed builder never
leaks attacker text into script fragments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: terminate osascript option parsing before notification argv

Passing untrusted title/body as trailing arguments still let a title that
looks like an osascript flag be consumed as an option: a title of "-e" would
turn the body into another script fragment, reopening the AppleScript
injection. Add a `--` option terminator before title/body so untrusted text
always lands in argv. Extend the regression tests to cover the flag-like
title vector.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: prove single-line AppleScript payload is host RCE, not just DoS

Add a single-line (Jira/Linear, no newline) PoC alongside the multi-line
Sentry payload. A newline statement separator is only needed for a SECOND
statement; the single-line payload instead injects an expression
`& (do shell script (...))` into the display-notification body argument, so
the command executes as a side effect while the line stays one valid
statement. Both shapes are host code execution. Extend the regression guard
to prove the argv fix neutralizes both.

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>
2026-07-19 10:32:55 +01:00
João Salavisa 7e386561f2 fix: treat git-describe builds as ahead of latest release (#1787)
* fix: treat git-describe builds as ahead of latest release

The update checker flagged "Update available" for local builds whose version
is a git-describe string like v0.79.0-60-g8fae44fb1, even though such a build
is 60 commits AHEAD of the v0.79.0 release. parseSemver treated the
"-60-g8fae44fb1" suffix as a pre-release of 0.79.0, which sorts below the
plain tag, so the comparator reported the build as older than latest.

Recognize the git-describe "-<N>-g<hash>[-dirty]" suffix as a commits-ahead
marker: such a build now sorts at or above its base tag and above any real
pre-release, so an ahead-of-latest build is no longer flagged as
update-available. Genuine pre-releases (rc.1, alpha.2) are unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: require default-length hash for git-describe detection

Tighten the git-describe suffix detector to require the abbreviated commit
hash to be at least 7 hex digits (git's default core.abbrev). This prevents a
contrived pre-release such as v1.0.0-3-ga from being misclassified as a
git-describe "ahead" build. kandev's Makefile runs `git describe` with the
default abbreviation, so real builds always meet the floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 00:54:04 +01:00
Carlos Florêncio 8fae44fb12 feat: unify configuration chat sessions (#1695) 2026-07-18 22:58:18 +01:00
Carlos Florêncio e9634fa636 feat: allow detaching subtasks from parent tasks (#1781) 2026-07-18 21:19:05 +01:00
Carlos Florêncio 21f811d5ec feat: add idle storage maintenance (#1699) 2026-07-18 19:08:44 +01:00
zensi-dev 4684390bc6 feat(acp): add Grok CLI driver (#1745) 2026-07-18 17:37:07 +01:00
Clément Fiere 69972b4f79 fix: freeze archived task state against late REVIEW writes (#1706) 2026-07-18 17:30:24 +01:00
João Salavisa 1ba81a7cd0 refactor(plugins): remove the unused tools feature (#1776)
Plugin `tools[]` overlapped with MCP, kandev's established mechanism for
exposing tools to agents (`internal/mcp/`), and the `InvokeTool` path was
never wired into agent tool sets — the `listTools` handler was
listing-only. Rather than maintain a half-built parallel surface, remove
it. If plugins ever contribute agent tools, they should feed through MCP,
not a separate `InvokeTool` RPC.

Removes:
- proto `InvokeTool` RPC + `ToolRequest`/`ToolResponse`/`ToolContext`
  messages (regenerated `*.pb.go` / `*_grpc.pb.go`)
- pkg/pluginsdk `InvokeTool` from the `Plugin` interface and
  `UnimplementedPlugin`, the Tool* Go-native types, and the
  `RemotePlugin`/`GRPCServer` dispatch
- internal/plugins `Service.InvokeTool`, the `listTools` handler +
  `GET /api/plugins/tools` route, `PluginToolDTO`, and the manifest
  `Tools` field + tool-name uniqueness validation
- frontend `PluginTool`/`PluginToolDTO` types, `listPluginTools`, and the
  manifest-card tools list
- fixture/e2e `InvokeTool` echo impl and the `tools:` manifest block

Test doubles that used the "crash" tool to exercise crash-restart now use
a "crash" webhook key instead. Internal specs (GRPC-CONTRACT, spec) drop
the tool RPC/feature; docs/public was already updated in #1774.

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 17:03:13 +01:00
Carlos Florêncio 5bd0e554bd fix(share): render conversation markdown (#1750) 2026-07-18 16:17:10 +01:00
Carlos Florêncio 0687f4a3d4 fix(workflows): hide system templates (#1765) 2026-07-18 16:15:33 +01:00
João Salavisa 9b7fa0ec9c fix(mcp): provide bounded access to CREATED sibling task descriptions (#1773)
* fix(mcp): provide bounded access to CREATED sibling task descriptions

Include the task description in the list_related_tasks_kandev projection so a
coordinating agent can read dependency metadata (e.g. a "Depends on:" line)
from a related task — including a CREATED sibling that has no session yet —
without falling back to an unbounded list_tasks_kandev call.

The relation graph is the bound: only parent/children/siblings/blockers are
ever projected, so descriptions never leak from unrelated tasks or across
workspaces. ListSiblings already returns CREATED (unstarted) tasks and loads
their descriptions from storage; only the MCP projection was dropping the
field.

Closes #1772

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): gate related-task descriptions by caller access

Address PR review: list_related_tasks_kandev ignored caller_task_id and
projected any named task's description, so a caller that learned an unrelated
or cross-workspace task ID could read it. Add ListRelatedForCaller, which
enforces the same relation/workspace read guard the document tools use
(self/ancestor/descendant/sibling/blocker, same workspace) for non-self
targets, and wire the MCP handler through it (access-denied maps to forbidden).
The un-gated ListRelated stays for trusted internal callers (GetTaskContext).

Add tests for the blocker/blocked-by description projection and the caller
access guard (unrelated + cross-workspace denied, relations allowed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(mcp): align related-tasks error mapping and empty-caller guard

Address PR review nits: route handleListRelatedTasks errors through
mapHandoffError (logging only non-access-denied errors) for parity with the
document handlers, and make ListRelatedForCaller deny an empty caller against a
non-self target instead of silently delegating to the ungated ListRelated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 13:22:32 +01:00
João Salavisa bb5a81a396 refactor(secrets): detect absent secret via ErrNotFound sentinel (#1770)
* refactor(secrets): detect absent secret via ErrNotFound sentinel

Replace the "secret not found:" string-prefix matching with an exported
secrets.ErrNotFound sentinel, matched via errors.Is. The store(s) now
return fmt.Errorf("%w: %s", ErrNotFound, id); the rendered message is
byte-identical so logs are unchanged.

Consumers migrated off the prefix:
- internal/plugins/config.go isSecretNotFound (behind host GetSecret/
  DeleteSecret and service.go vault rollback)
- internal/integrations/secretadapter Exists (jira/linear/sentry inherit
  the change through the shared adapter)

Adds a non-Postgres-gated secrets test asserting errors.Is(err,
ErrNotFound) plus message-unchanged coverage; updates the affected test
fakes to wrap the sentinel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(secrets): address review — eager t.Cleanup, faithful fake Delete

- sqlite_store_test: register sqlxDB close via t.Cleanup immediately
  after creation, before the NewMasterKeyProvider/Provide fatal paths
  (per the t.Cleanup-immediately convention).
- secretadapter_test: fakeStore.Delete now returns wrapped
  secrets.ErrNotFound for an absent id, mirroring the real store and
  the fakeSecretRevealer.Delete fake.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 01:08:29 +01:00
João Salavisa fae2303db3 feat(plugins): per-plugin settings pages driven by manifest config_schema (#1761)
* feat(plugins): per-plugin settings pages driven by manifest config_schema

Settings > Plugins rows now open /settings/plugins/:id — a detail page
showing the manifest plus a settings form generated from the plugin
author's config_schema. Secret fields (secret: true / format: password)
are masked on the operator API and kept on masked re-submits; a running
plugin restarts on save and reads its config (cleartext secrets included)
through the new Host GetConfig gRPC RPC.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): write plugin config file owner-only (0600)

Operator config can now carry cleartext secret values (config_schema
fields marked secret), so <id>.config.yml must not be world-readable —
match the 0600 mode the record file already uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(plugins): vault-backed plugin secrets — Get/Set/DeleteSecret RPCs and encrypted config secrets

Plugins get vault-backed secret primitives on the Host API
(GetSecret/SetSecret/DeleteSecret, capability-gated, namespaced to
plugin:<id>㊙️<key> so a plugin can only touch its own entries;
purged on uninstall). Secret config_schema fields now live in kandev's
AES-256-GCM vault too — <id>.config.yml persists only a vault reference,
and the Host GetConfig RPC resolves it back to cleartext for the plugin.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore(plugins): serve PR screenshots from the media/pr-1761-screenshots ref instead of the repo

The PR body now points at raw URLs pinned to an unmerged media ref, so
the binaries never land in main (PRs are squash-merged; only the branch
tip tree merges).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): address review feedback on settings validation and save flow

- integer form fields reject non-integral input instead of parseInt-truncating
- enum selections keep their schema-declared types (numeric enums round-trip)
- backend enum validation normalizes int-vs-float64 before comparison
- maskSecrets masks any non-zero secret value regardless of runtime type
- save flow splits PATCH from refetch: a refetch failure no longer reports a
  successful save as an error, and typed secrets are masked on screen
- optional enum fields gain a 'Not set' option so they can be cleared
- resolveConfigSecrets copies before mutating; restart-failure SetStatus
  errors are logged; SDK test fake returns a non-nil empty config map
- add missing tests: usePluginConfigForm hook, getPluginConfig client,
  integer/enum regression cases

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): harden secret config invariants from review

- reject non-string values on declared secret fields at validation time,
  closing the path where a numeric/boolean secret bypassed the vault and
  persisted cleartext in the config file
- delete removed secret fields' vault entries only AFTER the config commit
  succeeds, so a failed SetConfig never leaves the still-current config
  referencing a deleted vault entry
- uninstall fails closed: the plugin's vault namespace is purged before any
  destructive step, and a cleanup failure aborts the uninstall (retryable)
  instead of letting a reinstall inherit stale secrets

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): failure-atomic config secrets, quiesced uninstall purge, ctx propagation

- UpdateConfig is now failure-atomic for secret overwrites: storeConfigSecrets
  snapshots each vault entry before writing and returns a rollback that
  restores prior values (or deletes newly-created ones). A vault.Set failure
  mid-loop or a failed SetConfig commit rolls back, so a request reported as
  failed never changes the value a still-current config ref resolves to.
- Uninstall stops the plugin process BEFORE purging its vault namespace, so
  the plugin can no longer race the cleanup with a SetSecret between ListIDs
  and the deletes; the purge stays fail-closed and retryable.
- Thread the request context through Uninstall -> deletePluginSecrets instead
  of context.Background(), per the pass-context-through-chains convention.
- Consolidate the 'secret not found:' prefix check into isSecretNotFound
  (host.go + service rollback share one definition).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): detach rollback context, fail closed without a vault, reconcile uninstall status

- vault rollback closures now run on context.WithoutCancel(ctx): a request
  cancelled mid-save (browser closed) can no longer abort the rollback and
  leave the vault holding new values while the unchanged config still points
  at them (Greptile P1).
- storeConfigSecrets fails closed (errSecretVaultRequired) when a plugin
  declares secret config fields but no vault is wired, instead of silently
  persisting the secret in cleartext.
- vaultRestoreFunc distinguishes not-found from a genuine Reveal error: a
  backend read failure aborts the update before writing rather than risk a
  rollback that deletes a real secret; rollback now returns errors, joined
  into UpdateConfig's failure so an inconsistent vault is surfaced, not just
  logged.
- Uninstall reconciles lifecycle status when a secret-purge failure aborts
  after the process was stopped: a previously-running plugin transitions to
  error and notifies observers instead of being reported active with no
  process running.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): detach removed-secret cleanup ctx and clear stale save dirty flag

- cleanupRemovedConfigSecrets now runs on context.WithoutCancel(ctx), matching
  the rollback path, so a client disconnect right after the config commit can
  no longer cancel the delete and orphan now-unreferenced vault entries.
- tests: assert GetConfig succeeds before treating an empty map as proof that
  nothing persisted (a swallowed read error would be a false pass).
- web: on a post-save refetch failure, rebase initialValues onto the masked
  snapshot so the saved form no longer reads as dirty.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): fail tests on discarded GetConfig errors, drop setState-in-updater

- config_test: capture and fatal on GetConfig errors at the four remaining
  positive-assertion sites (a nil from a failed read would panic on the next
  map access instead of giving a clear failure), matching the earlier fix.
- web hook: compute the masked post-save snapshot from the current values and
  apply it to setValues + setInitialValues directly, instead of calling
  setInitialValues inside a setValues functional updater (a React anti-pattern
  that runs twice under StrictMode).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugins): skip validation of a secret field's own vault ref

- validateConfigSchema takes the plugin id and skips a secret field whose
  value is that field's config vault ref (an internal storage marker,
  already validated when first set). Without this, a field declaring both
  secret: true and enum: [...] 400s on every save that keeps the mask,
  because the enum check saw the vault ref instead of a user value. Freshly
  entered secret values are still validated; a foreign plugin's ref is not
  skipped. Covered by unit + service-level regression tests.
- surface mid-loop vault rollback failures in storeConfigSecrets via a
  warning instead of discarding them (double-fault observability).
- web: integer inputs get step=1 so browsers flag non-integral input in
  place (serializeConfigValues remains the backstop).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(plugins): split config secret tests into config_secrets_test.go

config_test.go had grown past the 800-line backend file-length limit
(revive), failing CI lint. Move the vault-backed config and plugin-scoped
secret tests (plus their fake-vault/store helpers) into a sibling file;
no test logic changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 22:13:58 +01:00