From 1f3ce2e9eabe41bfa98c7ed418c918ee257a8c74 Mon Sep 17 00:00:00 2001 From: Nate Hardison Date: Tue, 19 May 2026 16:34:45 +0000 Subject: [PATCH 01/15] docs: design spec for kiro-cli support --- .../2026-05-19-kiro-cli-support-design.md | 217 ++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 docs/plans/2026-05-19-kiro-cli-support-design.md diff --git a/docs/plans/2026-05-19-kiro-cli-support-design.md b/docs/plans/2026-05-19-kiro-cli-support-design.md new file mode 100644 index 00000000..a05223a7 --- /dev/null +++ b/docs/plans/2026-05-19-kiro-cli-support-design.md @@ -0,0 +1,217 @@ +# Kiro CLI Support — Design + +**Date:** 2026-05-19 +**Status:** Approved, pending implementation +**Author:** Nate Hardison (with Claude) + +## Summary + +Add first-class support for the [Kiro CLI](https://cli.kiro.dev) (`kiro-cli`) as +a MOAT agent provider, at full feature parity with the existing Codex and Claude +providers: credential grant, transparent proxy credential injection, container +config staging, local + remote MCP, runtime-context injection, and a `moat kiro` +command. Modeled on the kiro support in `/workspaces/agentbox` and the existing +`internal/providers/codex` implementation. + +## Background + +`kiro-cli` is AWS's agentic CLI. It authenticates to the Kiro/Q API at +`q..amazonaws.com` with a Bearer token and uses +`cognito-identity..amazonaws.com` for identity. agentbox installs it via +`curl -fsSL https://cli.kiro.dev/install | bash`, sets `KIRO_API_KEY=placeholder`, +assembles `~/.kiro/{agents,settings,steering}`, writes `settings/mcp.json`, and +relies on its proxy to inject the real Bearer token on `q.*.amazonaws.com` while +passing `cognito-identity.*.amazonaws.com` through unsigned. + +MOAT's architecture: each agent is a package under `internal/providers//` +implementing `provider.CredentialProvider` + `provider.AgentProvider`, registered +in `internal/providers/register.go`, with a dependency entry in +`internal/deps/registry.yaml` + install case in `internal/deps/install.go`, a +config section in `internal/config/config.go`, a dispatch block in +`internal/run/manager.go`, and a CLI command. The Codex provider is the closest +analog and the template for this work. + +## Decisions (settled during brainstorming) + +1. **Credential acquisition:** `moat grant kiro` prompts for a Kiro token / API + key (reads `KIRO_API_KEY` from env first, else prompts), stored encrypted like + codex/gemini. **Static credential — no refresh.** Re-grant when it expires. +2. **Scope:** Full parity with codex/claude — local MCP servers, runtime-context + file, and remote MCP relay are all in scope for v1. +3. **Implementation approach:** Dedicated `internal/providers/kiro` provider + mirroring codex (rejected: config-driven `configprovider` preset — cannot + implement `AgentProvider`; rejected: minimal-now/defer — fails parity goal). + +## Architecture + +### 1. CLI install — `internal/deps` + +- `registry.yaml`: add + ```yaml + kiro-cli: + description: Kiro CLI + type: custom + user-install: true + ``` +- `install.go`: add a `case "kiro-cli":` returning + - command: `curl -fsSL https://cli.kiro.dev/install | bash -s -- --force` + - env: `PATH` prepended with `/home/moatuser/.local/bin` + (mirrors the `claude-code` native-installer case) + +### 2. Credential provider — `internal/providers/kiro/` + +Files mirror the codex package: + +- **`provider.go`** — `Provider` implementing `CredentialProvider` + + `AgentProvider`. `init()` calls `provider.Register(&Provider{})`. + - `Name()` → `"kiro"` + - `Grant(ctx)` → delegates to `NewGrant().Execute(ctx)` + - `ConfigureProxy(p, cred)` → for each Kiro API host, call + `p.SetCredentialWithGrant(host, "Authorization", "Bearer "+cred.Token, "kiro")` + - `ContainerEnv(cred)` → `["KIRO_API_KEY="]` so kiro-cli runs in + API-key mode and emits the placeholder Bearer for the proxy to swap (exact + codex `OPENAI_API_KEY` pattern) + - `ContainerMounts` → `nil, "", nil` (staging-dir approach) + - `Cleanup` → no-op + - `ImpliedDependencies()` → `nil` +- **`constants.go`** — `KiroAPIKeyPlaceholder` (a syntactically plausible + placeholder; real auth via proxy) and the Kiro API host list. +- **`grant.go`** — `Grant.Execute(ctx)`: read `KIRO_API_KEY` env, else prompt; + return `*provider.Credential{Provider: "kiro", Token: ..., CreatedAt: now}`. + `HasCredential()` helper. No network validation in v1 (no documented + lightweight validation endpoint; revisit if one exists). +- **`agent.go`** — `PrepareContainer`: create temp staging dir, populate + `~/.kiro` layout (see §3), build env (`ContainerEnv` + `MOAT_KIRO_INIT=`mount), + return `ContainerConfig` with a read-only mount of the staging dir. +- **`cli.go`** — `RegisterCLI`, `NetworkHosts()`, `DefaultDependencies()`, + `GetCredentialName()`, `runKiro` via `cli.RunProvider`. + +**Hosts:** + +| Host pattern | Treatment | +|---|---| +| `q.*.amazonaws.com`, `*.q.*.amazonaws.com` | Bearer token injected | +| `cognito-identity.*.amazonaws.com` | allowlisted, passthrough (no injection) | +| `cli.kiro.dev` | build-time installer only | + +`NetworkHosts()` returns all of the above (so they're added to the run allow +list). `ConfigureProxy` only sets credentials on the `q.*` patterns. + +### 3. Agent provider — container config staging + +`PrepareContainer` writes a staging dir copied to `~/.kiro` by `moat-init.sh` +(via `MOAT_KIRO_INIT` env + read-only mount, exactly like +`MOAT_CODEX_INIT`/`CodexInitMountPath`). Staging contents: + +- `settings/cli.json` — `{"chat.disableTrustAllConfirmation": true}` so + `--trust-all-tools` works non-interactively (from agentbox). +- `settings/mcp.json` — `{"mcpServers": {...}}` built from: + - **local** servers: `kiro.mcp` entries → `{command, args, env, cwd}` + - **remote relay** servers: `opts.MCPServers[name]` (proxy relay URL) → + native HTTP entry if kiro-cli supports it, else a stdio bridge (see + Verification Points) +- `agents/default.json` — a minimal default agent that includes steering + resources (`file://~/.kiro/steering/**/*.md`) so the runtime-context file is + loaded. Modeled on agentbox `agents/default.json`, trimmed (no subagents). +- `steering/moat-context.md` — `opts.RuntimeContext` (the rendered markdown), + Kiro's equivalent of `AGENTS.md`/`CLAUDE.md`. Only written when non-empty. + +### 4. Config — `internal/config/config.go` + +```go +Kiro KiroConfig `yaml:"kiro,omitempty"` + +type KiroConfig struct { + SyncLogs *bool `yaml:"sync_logs,omitempty"` + MCP map[string]MCPServerSpec `yaml:"mcp,omitempty"` +} +``` +Add `ShouldSyncKiroLogs()` mirroring `ShouldSyncCodexLogs()` (enable when the +`kiro` grant is present, unless explicitly overridden). + +### 5. Run wiring — `internal/run/manager.go` + +- `needsKiroInit := slices.Contains(imgNeeds.initProviders, "kiro")` +- A kiro `PrepareContainer` dispatch block parallel to the codex block + (~lines 2085–2160): gated on + `needsKiroInit || hasKiroLocalMCP || ShouldSyncKiroLogs()`; fetch the `kiro` + credential from the store; build local MCP config from `Config.Kiro.MCP` + (with the same `grant`→placeholder-env handling codex does); build remote MCP + relay map from `Config.MCP`; call `PrepareContainer`; append mounts/env; + wire cleanup into the existing `cleanupAgentConfig` chain. + +### 6. CLI command — `moat kiro` + +`RegisterCLI` adds `moat kiro [workspace] [flags]` via `cli.RunProvider` +(mirrors `moat codex`): +- `BuildCommand`: base `kiro-cli chat --trust-all-tools --trust-tools=execute_bash`; + with `-p ` append `--no-interactive `; interactive otherwise + with optional initial prompt. +- `Dependencies`: `["kiro-cli", "git"]` (no node runtime needed — native binary). +- `NetworkHosts`: from `kiro.NetworkHosts()`. +- `ConfigureAgent`: set `cfg.Kiro.SyncLogs`. +- `agent: kiro` recognized in moat.yaml; included in the default-agent + resolution alongside claude/codex/gemini. + +### 7. Registration & constants + +- `internal/providers/register.go`: add + `_ "github.com/majorcontext/moat/internal/providers/kiro"` +- `internal/credential`: add `ProviderKiro = Provider("kiro")` constant. + +### 8. Documentation + +- `docs/content/reference/01-cli.md` — `moat kiro` and `moat grant kiro` +- `docs/content/reference/02-moat-yaml.md` — `kiro:` config section +- `docs/content/guides/` — a Kiro guide (parallel to existing agent guides) +- `CHANGELOG.md` — `### Added` entry under the next unreleased version, linked + to the PR + +## Error handling + +- Missing/expired credential: `moat kiro` dry-run note "No Kiro token + configured. Run `moat grant kiro`." (parallels codex's `DryRunNote`). +- `kiro.mcp.` referencing an undeclared grant: same explicit error codex + produces (`grant %q not declared in top-level grants list`). +- Install failure (`cli.kiro.dev` unreachable at build): surfaced by the + existing deps/build error path; no special handling. + +## Testing + +- `provider_test.go` — interface compliance asserts; `ConfigureProxy` sets the + expected header on each Kiro host; `ContainerEnv` returns the placeholder. +- `grant_test.go` — `Execute` reads `KIRO_API_KEY` env; prompts when unset + (table test with injected reader). +- `agent_test.go` — `PrepareContainer` writes `cli.json`, `mcp.json` (local + + remote), `agents/default.json`, and `steering/moat-context.md` with expected + contents; omits the steering file when `RuntimeContext` is empty. +- `config_test.go` — `KiroConfig` parses; `ShouldSyncKiroLogs` truth table. +- Reuse the existing manager/CLI test patterns for the dispatch + command. +- `make lint` / `go vet ./...` clean before commit. + +## Verification points (resolve during implementation) + +These cannot be confirmed here because `kiro-cli` is not runnable in this +environment. Each must be checked against a real `kiro-cli` (and the gatekeeper +proxy) before the corresponding code is finalized: + +1. **`~/.kiro` config layout** — confirm exact paths/filenames kiro-cli reads + for settings (`settings/cli.json`), MCP (`settings/mcp.json`), agents + (`agents/default.json`), and steering. Adjust staging layout to match. +2. **Remote MCP transport** — confirm whether kiro-cli `mcp.json` supports a + native HTTP server entry (e.g. `{"type":"http","url":...}`). If yes, use it + for the relay; if not, ship a stdio bridge command (agentbox's + `mcp-connect ` approach) and document it. +3. **Wildcard credential injection** — confirm gatekeeper v0.2.0 matches + wildcard host patterns (`q.*.amazonaws.com`) for credential injection. + Evidence suggests yes (codex registers `*.openai.com`; `configprovider` + accepts wildcard hosts), but verify; if only exact hosts match, register the + concrete regional hosts the user needs (default `q.us-east-1.amazonaws.com`) + and document how to add more. + +## Out of scope (v1) + +- Token refresh / OAuth device-login flow (static credential only) +- Persistent Kiro sessions volume across runs +- Kiro skills layering (`~/.kiro/skills`) +- Kiro subagents in the default agent config From 1f112ddd7312bee793cbc37590df632de66eac0e Mon Sep 17 00:00:00 2001 From: Nate Hardison Date: Tue, 19 May 2026 16:42:32 +0000 Subject: [PATCH 02/15] docs: confirm native HTTP MCP, scope wildcard fallback in kiro spec --- .../2026-05-19-kiro-cli-support-design.md | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/docs/plans/2026-05-19-kiro-cli-support-design.md b/docs/plans/2026-05-19-kiro-cli-support-design.md index a05223a7..c0f515f9 100644 --- a/docs/plans/2026-05-19-kiro-cli-support-design.md +++ b/docs/plans/2026-05-19-kiro-cli-support-design.md @@ -1,9 +1,5 @@ # Kiro CLI Support — Design -**Date:** 2026-05-19 -**Status:** Approved, pending implementation -**Author:** Nate Hardison (with Claude) - ## Summary Add first-class support for the [Kiro CLI](https://cli.kiro.dev) (`kiro-cli`) as @@ -108,8 +104,10 @@ list). `ConfigureProxy` only sets credentials on the `q.*` patterns. - `settings/mcp.json` — `{"mcpServers": {...}}` built from: - **local** servers: `kiro.mcp` entries → `{command, args, env, cwd}` - **remote relay** servers: `opts.MCPServers[name]` (proxy relay URL) → - native HTTP entry if kiro-cli supports it, else a stdio bridge (see - Verification Points) + native HTTP server entry. kiro-cli supports remote HTTP MCP servers + natively — see https://kiro.dev/docs/cli/mcp/configuration/#remote-server + for the exact JSON shape; confirm the precise key names against that doc + during implementation. - `agents/default.json` — a minimal default agent that includes steering resources (`file://~/.kiro/steering/**/*.md`) so the runtime-context file is loaded. Modeled on agentbox `agents/default.json`, trimmed (no subagents). @@ -198,16 +196,17 @@ proxy) before the corresponding code is finalized: 1. **`~/.kiro` config layout** — confirm exact paths/filenames kiro-cli reads for settings (`settings/cli.json`), MCP (`settings/mcp.json`), agents (`agents/default.json`), and steering. Adjust staging layout to match. -2. **Remote MCP transport** — confirm whether kiro-cli `mcp.json` supports a - native HTTP server entry (e.g. `{"type":"http","url":...}`). If yes, use it - for the relay; if not, ship a stdio bridge command (agentbox's - `mcp-connect ` approach) and document it. +2. **Remote MCP JSON shape** — kiro-cli natively supports remote HTTP MCP + servers (confirmed via + https://kiro.dev/docs/cli/mcp/configuration/#remote-server). Confirm the + precise key names for the HTTP server entry against that doc and emit them + in `settings/mcp.json`. 3. **Wildcard credential injection** — confirm gatekeeper v0.2.0 matches wildcard host patterns (`q.*.amazonaws.com`) for credential injection. Evidence suggests yes (codex registers `*.openai.com`; `configprovider` - accepts wildcard hosts), but verify; if only exact hosts match, register the - concrete regional hosts the user needs (default `q.us-east-1.amazonaws.com`) - and document how to add more. + accepts wildcard hosts). **If wildcards are not supported, scope to the + single concrete host `q.us-east-1.amazonaws.com`** (per user decision) and + document how to add other regions. ## Out of scope (v1) From a2dfd837dce383d995070c5052500913e55ecc9d Mon Sep 17 00:00:00 2001 From: Nate Hardison Date: Tue, 19 May 2026 16:50:22 +0000 Subject: [PATCH 03/15] docs: implementation plan for kiro-cli support --- .../plans/2026-05-19-kiro-cli-support-plan.md | 1548 +++++++++++++++++ 1 file changed, 1548 insertions(+) create mode 100644 docs/plans/2026-05-19-kiro-cli-support-plan.md diff --git a/docs/plans/2026-05-19-kiro-cli-support-plan.md b/docs/plans/2026-05-19-kiro-cli-support-plan.md new file mode 100644 index 00000000..5a21ee65 --- /dev/null +++ b/docs/plans/2026-05-19-kiro-cli-support-plan.md @@ -0,0 +1,1548 @@ +# Kiro CLI Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `kiro-cli` as a first-class MOAT agent provider at full parity with the Codex provider (grant, proxy injection, container config staging, local + remote MCP, runtime context, `moat kiro` command). + +**Architecture:** A new `internal/providers/kiro` package implementing `provider.CredentialProvider` + `provider.AgentProvider`, mirroring `internal/providers/codex`. Standard wiring: deps registry/install, credential constant, config section, imageneeds detection, run-manager dispatch block, auto-registered CLI command. Auth uses the codex pattern — a placeholder `KIRO_API_KEY` env in the container while the proxy injects the real Bearer token on `q.*.amazonaws.com`. + +**Tech Stack:** Go, Cobra, the MOAT provider/credential/deps/config/run packages. + +**Spec:** `docs/plans/2026-05-19-kiro-cli-support-design.md`. Read it before starting. + +**Working directory:** worktree `/workspace/.claude/worktrees/kiro-cli-support`, branch `worktree-kiro-cli-support`. Run all commands from there. + +**Conventions:** +- TDD: failing test first, then minimal code. +- Run `go build ./...` after each task; `make lint` (fallback `go vet ./...`) before each commit. +- Commit messages: Conventional Commits, no `Co-Authored-By`. +- Mirror codex naming exactly so reviewers can diff the two packages. + +--- + +### Task 1: Register `kiro-cli` dependency + +**Files:** +- Modify: `internal/deps/registry.yaml` (add `kiro-cli` entry near `gemini-cli`) +- Modify: `internal/deps/install.go` (add `case "kiro-cli":` in `getCustomCommands`) +- Test: `internal/deps/install_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `internal/deps/install_test.go`: + +```go +func TestGetCustomCommandsKiroCLI(t *testing.T) { + cmds := getCustomCommands("kiro-cli", "") + if len(cmds.Commands) != 1 { + t.Fatalf("expected 1 command, got %d: %v", len(cmds.Commands), cmds.Commands) + } + want := "curl -fsSL https://cli.kiro.dev/install | bash -s -- --force" + if cmds.Commands[0] != want { + t.Errorf("command = %q, want %q", cmds.Commands[0], want) + } + if got := cmds.EnvVars["PATH"]; got != "/home/moatuser/.local/bin:$PATH" { + t.Errorf("PATH = %q, want %q", got, "/home/moatuser/.local/bin:$PATH") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/deps/ -run TestGetCustomCommandsKiroCLI -v` +Expected: FAIL (kiro-cli falls through to the default `InstallCommands{}`, length 0). + +- [ ] **Step 3: Add the install case** + +In `internal/deps/install.go`, inside `getCustomCommands`, immediately after the `case "claude-code":` block (ends with its closing `}` before `case "protoc":`), add: + +```go + case "kiro-cli": + // Native installer from cli.kiro.dev; binary lands in ~/.local/bin. + return InstallCommands{ + Commands: []string{ + `curl -fsSL https://cli.kiro.dev/install | bash -s -- --force`, + }, + EnvVars: map[string]string{ + "PATH": "/home/moatuser/.local/bin:$PATH", + }, + } +``` + +- [ ] **Step 4: Add the registry entry** + +In `internal/deps/registry.yaml`, after the `gemini-cli:` block (the 4-line block ending with `requires: [node]`) and before `graphite-cli:`, add: + +```yaml +kiro-cli: + description: Kiro CLI + type: custom + user-install: true +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/deps/ -run TestGetCustomCommandsKiroCLI -v && go test ./internal/deps/` +Expected: PASS (all deps tests). + +- [ ] **Step 6: Commit** + +```bash +cd /workspace/.claude/worktrees/kiro-cli-support +go vet ./internal/deps/ +git add internal/deps/registry.yaml internal/deps/install.go internal/deps/install_test.go +git commit -m "feat(deps): register kiro-cli dependency" +``` + +--- + +### Task 2: Add `ProviderKiro` credential constant + +**Files:** +- Modify: `internal/credential/types.go` (constant + `KnownProviders` + `IsKnownProvider`) +- Test: `internal/credential/types_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `internal/credential/types_test.go`: + +```go +func TestProviderKiroIsKnown(t *testing.T) { + if ProviderKiro != "kiro" { + t.Errorf("ProviderKiro = %q, want %q", ProviderKiro, "kiro") + } + if !IsKnownProvider(ProviderKiro) { + t.Error("IsKnownProvider(ProviderKiro) = false, want true") + } + found := false + for _, p := range KnownProviders() { + if p == ProviderKiro { + found = true + } + } + if !found { + t.Error("KnownProviders() does not include ProviderKiro") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/credential/ -run TestProviderKiroIsKnown -v` +Expected: FAIL (undefined: ProviderKiro — compile error). + +- [ ] **Step 3: Add the constant and register it** + +In `internal/credential/types.go`: + +1. After the `ProviderMeta Provider = "meta"` line, add: + ```go + ProviderKiro Provider = "kiro" + ``` +2. In `KnownProviders`, change the `base` slice to append `ProviderKiro`: + ```go + base := []Provider{ProviderGitHub, ProviderAWS, ProviderAnthropic, ProviderClaude, ProviderOpenAI, ProviderGemini, ProviderNpm, ProviderGraphite, ProviderMeta, ProviderKiro} + ``` +3. In `IsKnownProvider`, add `ProviderKiro` to the `case` list: + ```go + case ProviderGitHub, ProviderAWS, ProviderAnthropic, ProviderClaude, ProviderOpenAI, ProviderGemini, ProviderNpm, ProviderGraphite, ProviderMeta, ProviderKiro: + ``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/credential/ -run TestProviderKiroIsKnown -v && go test ./internal/credential/` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +go vet ./internal/credential/ +git add internal/credential/types.go internal/credential/types_test.go +git commit -m "feat(credential): add ProviderKiro constant" +``` + +--- + +### Task 3: Kiro provider package — constants + credential provider + +**Files:** +- Create: `internal/providers/kiro/constants.go` +- Create: `internal/providers/kiro/provider.go` +- Create: `internal/providers/kiro/doc.go` +- Test: `internal/providers/kiro/provider_test.go` + +- [ ] **Step 1: Create the package doc and constants** + +Create `internal/providers/kiro/doc.go`: + +```go +// Package kiro implements the Kiro CLI agent provider for Moat. +// +// It mirrors the codex provider: a placeholder KIRO_API_KEY is set in the +// container while the Moat proxy injects the real Bearer token on the Kiro +// API hosts. Container config is staged and copied to ~/.kiro by moat-init. +package kiro +``` + +Create `internal/providers/kiro/constants.go`: + +```go +package kiro + +// KiroAPIKeyPlaceholder is a syntactically plausible placeholder API key. +// kiro-cli runs in API-key mode when KIRO_API_KEY is set and sends this +// value as a Bearer token; the Moat proxy replaces it with the real token +// at the network layer. The real token never enters the container. +const KiroAPIKeyPlaceholder = "kiro-moat-proxy-injected-placeholder-000000000000000000000000000000" + +// KiroInitMountPath is where the staging directory is mounted in containers. +const KiroInitMountPath = "/moat/kiro-init" + +// kiroAPIHosts are the hosts the proxy injects the Kiro Bearer token for. +// +// VERIFICATION POINT (spec §Verification 3): if gatekeeper v0.2.0 does not +// match wildcard host patterns for credential injection, replace this slice +// with the single concrete host "q.us-east-1.amazonaws.com" and document how +// to add other regions. Confirm during implementation (see Task 10 Step "verify"). +var kiroAPIHosts = []string{ + "q.*.amazonaws.com", + "*.q.*.amazonaws.com", +} + +// kiroPassthroughHosts are allowlisted but receive no credential injection. +var kiroPassthroughHosts = []string{ + "cognito-identity.*.amazonaws.com", +} +``` + +- [ ] **Step 2: Write the failing provider test** + +Create `internal/providers/kiro/provider_test.go`: + +```go +package kiro + +import ( + "testing" + + "github.com/majorcontext/moat/internal/provider" +) + +type mockProxyConfigurer struct { + headers map[string]map[string]string +} + +func newMockProxy() *mockProxyConfigurer { + return &mockProxyConfigurer{headers: make(map[string]map[string]string)} +} + +func (m *mockProxyConfigurer) SetCredential(host, value string) {} +func (m *mockProxyConfigurer) SetCredentialHeader(host, h, v string) { + if m.headers[host] == nil { + m.headers[host] = map[string]string{} + } + m.headers[host][h] = v +} +func (m *mockProxyConfigurer) SetCredentialWithGrant(host, h, v, g string) { + if m.headers[host] == nil { + m.headers[host] = map[string]string{} + } + m.headers[host][h] = v +} +func (m *mockProxyConfigurer) AddExtraHeader(host, h, v string) {} +func (m *mockProxyConfigurer) AddResponseTransformer(host string, _ provider.ResponseTransformer) {} +func (m *mockProxyConfigurer) RemoveRequestHeader(host, h string) {} +func (m *mockProxyConfigurer) SetTokenSubstitution(host, p, r string) {} + +func TestProviderName(t *testing.T) { + if (&Provider{}).Name() != "kiro" { + t.Errorf("Name() = %q, want kiro", (&Provider{}).Name()) + } +} + +func TestConfigureProxyInjectsBearerOnKiroHosts(t *testing.T) { + m := newMockProxy() + (&Provider{}).ConfigureProxy(m, &provider.Credential{Token: "real-token"}) + for _, host := range kiroAPIHosts { + got := m.headers[host]["Authorization"] + if got != "Bearer real-token" { + t.Errorf("host %s Authorization = %q, want %q", host, got, "Bearer real-token") + } + } + for _, host := range kiroPassthroughHosts { + if _, ok := m.headers[host]; ok { + t.Errorf("passthrough host %s should not receive credential injection", host) + } + } +} + +func TestContainerEnvSetsPlaceholder(t *testing.T) { + env := (&Provider{}).ContainerEnv(&provider.Credential{Token: "real"}) + want := "KIRO_API_KEY=" + KiroAPIKeyPlaceholder + if len(env) != 1 || env[0] != want { + t.Errorf("ContainerEnv() = %v, want [%q]", env, want) + } +} + +func TestInterfaceCompliance(t *testing.T) { + var _ provider.CredentialProvider = (*Provider)(nil) + var _ provider.AgentProvider = (*Provider)(nil) +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `go test ./internal/providers/kiro/ -v` +Expected: FAIL (undefined: Provider — compile error). + +- [ ] **Step 4: Create the credential provider** + +Create `internal/providers/kiro/provider.go`: + +```go +package kiro + +import ( + "context" + + "github.com/majorcontext/moat/internal/provider" +) + +// Provider implements provider.CredentialProvider and provider.AgentProvider +// for the Kiro CLI. +type Provider struct{} + +var ( + _ provider.CredentialProvider = (*Provider)(nil) + _ provider.AgentProvider = (*Provider)(nil) +) + +func init() { + provider.Register(&Provider{}) +} + +// Name returns the provider identifier. +func (p *Provider) Name() string { return "kiro" } + +// Grant acquires a Kiro token interactively or from environment. +func (p *Provider) Grant(ctx context.Context) (*provider.Credential, error) { + return NewGrant().Execute(ctx) +} + +// ConfigureProxy injects the real Kiro Bearer token on the Kiro API hosts. +// Passthrough hosts receive no injection (they are only allowlisted). +func (p *Provider) ConfigureProxy(proxy provider.ProxyConfigurer, cred *provider.Credential) { + for _, host := range kiroAPIHosts { + proxy.SetCredentialWithGrant(host, "Authorization", "Bearer "+cred.Token, "kiro") + } +} + +// ContainerEnv sets a placeholder KIRO_API_KEY. kiro-cli runs in API-key +// mode and sends the placeholder; the proxy swaps in the real token. +func (p *Provider) ContainerEnv(cred *provider.Credential) []string { + return []string{"KIRO_API_KEY=" + KiroAPIKeyPlaceholder} +} + +// ContainerMounts returns no direct mounts — Kiro uses the staging-dir +// approach populated by PrepareContainer. +func (p *Provider) ContainerMounts(cred *provider.Credential, containerHome string) ([]provider.MountConfig, string, error) { + return nil, "", nil +} + +// Cleanup is a no-op; the staging directory is cleaned by the caller. +func (p *Provider) Cleanup(cleanupPath string) {} + +// ImpliedDependencies returns no implied dependencies. +func (p *Provider) ImpliedDependencies() []string { return nil } +``` + +(`Grant`, `PrepareContainer`, and `RegisterCLI` are completed in Tasks 4–6. The package will not build until Task 6; that is expected — do not run the full build until Step 6 here is reached for the *unit* file, and the package compiles after Task 6.) + +- [ ] **Step 5: Stub Grant/PrepareContainer/RegisterCLI so the package compiles for this task's test** + +To keep Task 3 independently testable, create temporary stubs that Tasks 4–6 replace. Create `internal/providers/kiro/agent.go`: + +```go +package kiro + +import ( + "context" + "errors" + + "github.com/majorcontext/moat/internal/provider" +) + +// PrepareContainer is implemented in Task 5. +func (p *Provider) PrepareContainer(ctx context.Context, opts provider.PrepareOpts) (*provider.ContainerConfig, error) { + return nil, errors.New("not implemented") +} +``` + +Create `internal/providers/kiro/grant.go`: + +```go +package kiro + +import ( + "context" + "errors" + + "github.com/majorcontext/moat/internal/provider" +) + +// Grant is implemented in Task 4. +type Grant struct{} + +func NewGrant() *Grant { return &Grant{} } + +func (g *Grant) Execute(ctx context.Context) (*provider.Credential, error) { + return nil, errors.New("not implemented") +} +``` + +Create `internal/providers/kiro/cli.go`: + +```go +package kiro + +import "github.com/spf13/cobra" + +// RegisterCLI is implemented in Task 6. +func (p *Provider) RegisterCLI(root *cobra.Command) {} +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `go test ./internal/providers/kiro/ -v` +Expected: PASS (4 tests). `go build ./internal/providers/kiro/` succeeds. + +- [ ] **Step 7: Commit** + +```bash +go vet ./internal/providers/kiro/ +git add internal/providers/kiro/ +git commit -m "feat(kiro): add credential provider (proxy injection + container env)" +``` + +--- + +### Task 4: Kiro grant flow + +**Files:** +- Modify: `internal/providers/kiro/grant.go` (replace the Task 3 stub) +- Test: `internal/providers/kiro/grant_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/providers/kiro/grant_test.go`: + +```go +package kiro + +import ( + "context" + "strings" + "testing" +) + +func TestExecuteReadsEnv(t *testing.T) { + t.Setenv("KIRO_API_KEY", "env-token-123") + cred, err := NewGrant().Execute(context.Background()) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if cred.Token != "env-token-123" { + t.Errorf("Token = %q, want env-token-123", cred.Token) + } + if cred.Provider != "kiro" { + t.Errorf("Provider = %q, want kiro", cred.Provider) + } + if cred.CreatedAt.IsZero() { + t.Error("CreatedAt is zero") + } +} + +func TestExecutePromptsWhenNoEnv(t *testing.T) { + t.Setenv("KIRO_API_KEY", "") + g := NewGrant() + g.readToken = func() (string, error) { return " prompted-token ", nil } + cred, err := g.Execute(context.Background()) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if cred.Token != "prompted-token" { + t.Errorf("Token = %q, want trimmed prompted-token", cred.Token) + } +} + +func TestExecuteEmptyTokenIsError(t *testing.T) { + t.Setenv("KIRO_API_KEY", "") + g := NewGrant() + g.readToken = func() (string, error) { return " ", nil } + _, err := g.Execute(context.Background()) + if err == nil || !strings.Contains(err.Error(), "empty") { + t.Fatalf("expected empty-token error, got %v", err) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/providers/kiro/ -run TestExecute -v` +Expected: FAIL (`g.readToken` undefined; stub returns "not implemented"). + +- [ ] **Step 3: Implement the grant** + +Replace the entire contents of `internal/providers/kiro/grant.go` with: + +```go +package kiro + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/majorcontext/moat/internal/credential" + "github.com/majorcontext/moat/internal/provider" +) + +// Grant handles the Kiro token grant. It reads KIRO_API_KEY from the +// environment, falling back to an interactive prompt. The token is stored +// as-is and validated only by the upstream API at run time (no local +// validation endpoint — see spec). +type Grant struct { + // readToken reads a token interactively. Overridable in tests. + readToken func() (string, error) +} + +// NewGrant creates a Grant with the default interactive reader. +func NewGrant() *Grant { + return &Grant{readToken: promptForToken} +} + +func promptForToken() (string, error) { + fmt.Print("Enter your Kiro token: ") + r := bufio.NewReader(os.Stdin) + line, err := r.ReadString('\n') + if err != nil && line == "" { + return "", fmt.Errorf("reading token: %w", err) + } + return line, nil +} + +// Execute performs the grant. Returns a provider.Credential; the CLI wrapper +// persists it to the credential store. +func (g *Grant) Execute(ctx context.Context) (*provider.Credential, error) { + token := os.Getenv("KIRO_API_KEY") + if token != "" { + fmt.Println("Using token from KIRO_API_KEY environment variable") + } else { + read := g.readToken + if read == nil { + read = promptForToken + } + var err error + token, err = read() + if err != nil { + return nil, fmt.Errorf("reading Kiro token: %w", err) + } + } + + token = strings.TrimSpace(token) + if token == "" { + return nil, fmt.Errorf("Kiro token is empty") + } + + return &provider.Credential{ + Provider: "kiro", + Token: token, + CreatedAt: time.Now(), + }, nil +} + +// HasCredential reports whether a Kiro credential exists in the store. +func HasCredential() bool { + key, err := credential.DefaultEncryptionKey() + if err != nil { + return false + } + store, err := credential.NewFileStore(credential.DefaultStoreDir(), key) + if err != nil { + return false + } + cred, err := store.Get(credential.ProviderKiro) + return err == nil && cred != nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/providers/kiro/ -v` +Expected: PASS (all kiro tests, including Task 3's). + +- [ ] **Step 5: Commit** + +```bash +go vet ./internal/providers/kiro/ +git add internal/providers/kiro/grant.go internal/providers/kiro/grant_test.go +git commit -m "feat(kiro): implement token grant flow" +``` + +--- + +### Task 5: Kiro container config staging (`PrepareContainer`) + +**Files:** +- Modify: `internal/providers/kiro/agent.go` (replace the Task 3 stub) +- Test: `internal/providers/kiro/agent_test.go` + +**Reference for MCP JSON shape:** `https://kiro.dev/docs/cli/mcp/configuration/#remote-server` (memory: kiro-mcp-config-docs). Remote HTTP servers use a `url` (+ optional `headers`) entry; local servers use `command`/`args`. Confirm exact key names against that doc before finalizing — if they differ, adjust `mcpHTTPServer`/`mcpLocalServer` struct tags only. + +- [ ] **Step 1: Write the failing test** + +Create `internal/providers/kiro/agent_test.go`: + +```go +package kiro + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/majorcontext/moat/internal/provider" +) + +func readJSON(t *testing.T, path string) map[string]any { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal %s: %v", path, err) + } + return m +} + +func TestPrepareContainerWritesConfig(t *testing.T) { + p := &Provider{} + cfg, err := p.PrepareContainer(context.Background(), provider.PrepareOpts{ + Credential: &provider.Credential{Provider: "kiro", Token: "t"}, + ContainerHome: "/home/moatuser", + RuntimeContext: "# Moat context\nhello", + LocalMCPServers: map[string]provider.LocalMCPServerConfig{ + "local1": {Command: "mcp-local", Args: []string{"--x"}}, + }, + MCPServers: map[string]provider.MCPServerConfig{ + "remote1": {URL: "http://proxy/mcp/tok/remote1", Headers: map[string]string{"X-A": "b"}}, + }, + }) + if err != nil { + t.Fatalf("PrepareContainer() error = %v", err) + } + defer cfg.Cleanup() + + dir := cfg.StagingDir + + cli := readJSON(t, filepath.Join(dir, "settings", "cli.json")) + if cli["chat.disableTrustAllConfirmation"] != true { + t.Errorf("cli.json missing chat.disableTrustAllConfirmation=true: %v", cli) + } + + mcp := readJSON(t, filepath.Join(dir, "settings", "mcp.json")) + servers, ok := mcp["mcpServers"].(map[string]any) + if !ok { + t.Fatalf("mcp.json mcpServers not an object: %v", mcp) + } + if _, ok := servers["local1"]; !ok { + t.Error("mcp.json missing local1") + } + if _, ok := servers["remote1"]; !ok { + t.Error("mcp.json missing remote1") + } + + if _, err := os.Stat(filepath.Join(dir, "agents", "default.json")); err != nil { + t.Errorf("agents/default.json missing: %v", err) + } + + ctx, err := os.ReadFile(filepath.Join(dir, "steering", "moat-context.md")) + if err != nil { + t.Fatalf("steering/moat-context.md: %v", err) + } + if string(ctx) != "# Moat context\nhello" { + t.Errorf("steering content = %q", string(ctx)) + } + + foundEnv := false + for _, e := range cfg.Env { + if e == "KIRO_API_KEY="+KiroAPIKeyPlaceholder { + foundEnv = true + } + } + if !foundEnv { + t.Errorf("env missing KIRO_API_KEY placeholder: %v", cfg.Env) + } + if len(cfg.Mounts) != 1 || cfg.Mounts[0].Target != KiroInitMountPath || !cfg.Mounts[0].ReadOnly { + t.Errorf("unexpected mounts: %+v", cfg.Mounts) + } +} + +func TestPrepareContainerOmitsEmptySteering(t *testing.T) { + p := &Provider{} + cfg, err := p.PrepareContainer(context.Background(), provider.PrepareOpts{ + Credential: &provider.Credential{Provider: "kiro", Token: "t"}, + ContainerHome: "/home/moatuser", + }) + if err != nil { + t.Fatalf("PrepareContainer() error = %v", err) + } + defer cfg.Cleanup() + if _, err := os.Stat(filepath.Join(cfg.StagingDir, "steering", "moat-context.md")); !os.IsNotExist(err) { + t.Errorf("steering file should not exist when RuntimeContext empty (err=%v)", err) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/providers/kiro/ -run TestPrepareContainer -v` +Expected: FAIL (stub returns "not implemented"). + +- [ ] **Step 3: Implement `PrepareContainer`** + +Replace the entire contents of `internal/providers/kiro/agent.go` with: + +```go +package kiro + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/majorcontext/moat/internal/provider" +) + +// mcpLocalServer is a stdio MCP server entry in ~/.kiro/settings/mcp.json. +type mcpLocalServer struct { + Command string `json:"command"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` + Cwd string `json:"cwd,omitempty"` +} + +// mcpHTTPServer is a remote HTTP MCP server entry. Key names per +// https://kiro.dev/docs/cli/mcp/configuration/#remote-server — confirm +// during implementation (see Task 5 header note). +type mcpHTTPServer struct { + URL string `json:"url"` + Headers map[string]string `json:"headers,omitempty"` +} + +type mcpFile struct { + MCPServers map[string]any `json:"mcpServers"` +} + +// PrepareContainer stages a ~/.kiro tree (settings, agents, steering) that +// moat-init copies into the container. The real token is never written — +// auth is via the proxy. +func (p *Provider) PrepareContainer(ctx context.Context, opts provider.PrepareOpts) (*provider.ContainerConfig, error) { + tmpDir, err := os.MkdirTemp("", "moat-kiro-staging-*") + if err != nil { + return nil, fmt.Errorf("creating temp dir: %w", err) + } + cleanup := func() { os.RemoveAll(tmpDir) } + + for _, sub := range []string{"settings", "agents", "steering"} { + if mkErr := os.MkdirAll(filepath.Join(tmpDir, sub), 0o755); mkErr != nil { + cleanup() + return nil, fmt.Errorf("creating %s dir: %w", sub, mkErr) + } + } + + // settings/cli.json — allow --trust-all-tools non-interactively. + cliJSON, _ := json.MarshalIndent(map[string]any{ + "chat.disableTrustAllConfirmation": true, + }, "", " ") + if wErr := os.WriteFile(filepath.Join(tmpDir, "settings", "cli.json"), cliJSON, 0o600); wErr != nil { + cleanup() + return nil, fmt.Errorf("writing cli.json: %w", wErr) + } + + // settings/mcp.json — local + remote relay servers. + servers := map[string]any{} + for name, c := range opts.LocalMCPServers { + servers[name] = mcpLocalServer{Command: c.Command, Args: c.Args, Env: c.Env, Cwd: c.Cwd} + } + for name, c := range opts.MCPServers { + servers[name] = mcpHTTPServer{URL: c.URL, Headers: c.Headers} + } + mcpJSON, mErr := json.MarshalIndent(mcpFile{MCPServers: servers}, "", " ") + if mErr != nil { + cleanup() + return nil, fmt.Errorf("marshaling mcp.json: %w", mErr) + } + if wErr := os.WriteFile(filepath.Join(tmpDir, "settings", "mcp.json"), mcpJSON, 0o600); wErr != nil { + cleanup() + return nil, fmt.Errorf("writing mcp.json: %w", wErr) + } + + // agents/default.json — trimmed default agent including steering resources. + agentJSON, _ := json.MarshalIndent(map[string]any{ + "name": "default", + "description": "Moat sandbox agent", + "tools": []string{"*"}, + "resources": []string{ + "file://README.md", + "file://AGENTS.md", + "file://.kiro/steering/**/*.md", + "file://~/.kiro/steering/**/*.md", + }, + "includeMcpJson": true, + }, "", " ") + if wErr := os.WriteFile(filepath.Join(tmpDir, "agents", "default.json"), agentJSON, 0o600); wErr != nil { + cleanup() + return nil, fmt.Errorf("writing default.json: %w", wErr) + } + + // steering/moat-context.md — runtime context (only when non-empty). + if opts.RuntimeContext != "" { + if wErr := os.WriteFile(filepath.Join(tmpDir, "steering", "moat-context.md"), []byte(opts.RuntimeContext), 0o644); wErr != nil { + cleanup() + return nil, fmt.Errorf("writing steering context: %w", wErr) + } + } + + env := p.ContainerEnv(opts.Credential) + env = append(env, "MOAT_KIRO_INIT="+KiroInitMountPath) + + return &provider.ContainerConfig{ + Env: env, + Mounts: []provider.MountConfig{ + {Source: tmpDir, Target: KiroInitMountPath, ReadOnly: true}, + }, + StagingDir: tmpDir, + Cleanup: cleanup, + }, nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/providers/kiro/ -v` +Expected: PASS (all kiro tests). + +- [ ] **Step 5: Commit** + +```bash +go vet ./internal/providers/kiro/ +git add internal/providers/kiro/agent.go internal/providers/kiro/agent_test.go +git commit -m "feat(kiro): stage ~/.kiro container config (settings, mcp, agent, steering)" +``` + +--- + +### Task 6: Config section + log sync + +> Done before the CLI task because `internal/providers/kiro/cli.go` references `cfg.Kiro`. + +**Files:** +- Modify: `internal/config/config.go` (add `Kiro` field, `KiroConfig` type, `ShouldSyncKiroLogs`, MCP validation) +- Test: `internal/config/config_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `internal/config/config_test.go`: + +```go +func TestKiroConfigParsesAndSyncLogs(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "moat.yaml") + os.WriteFile(path, []byte("agent: kiro\ngrants: [kiro]\nkiro:\n mcp:\n s1:\n command: foo\n"), 0644) + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if _, ok := cfg.Kiro.MCP["s1"]; !ok { + t.Errorf("kiro.mcp.s1 not parsed: %+v", cfg.Kiro) + } + if !cfg.ShouldSyncKiroLogs() { + t.Error("ShouldSyncKiroLogs() = false, want true (kiro grant present)") + } + no := false + cfg.Kiro.SyncLogs = &no + if cfg.ShouldSyncKiroLogs() { + t.Error("ShouldSyncKiroLogs() = true, want false (explicitly disabled)") + } +} +``` + +(If `Load` is not the loader name used elsewhere in `config_test.go`, match the existing pattern in that file — check a neighboring test like the codex one.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/config/ -run TestKiroConfigParsesAndSyncLogs -v` +Expected: FAIL (cfg.Kiro undefined — compile error). + +- [ ] **Step 3: Add the config field and type** + +In `internal/config/config.go`: + +1. After the `Gemini GeminiConfig \`yaml:"gemini,omitempty"\`` field, add: + ```go + Kiro KiroConfig `yaml:"kiro,omitempty"` + ``` +2. After the `GeminiConfig` struct definition, add: + ```go +// KiroConfig configures Kiro CLI integration options. +type KiroConfig struct { + // SyncLogs controls whether Kiro session logs are synced to the host. + // Default: false, unless the "kiro" grant is configured (then true). + SyncLogs *bool `yaml:"sync_logs,omitempty"` + + // MCP defines local MCP (Model Context Protocol) server configurations. + MCP map[string]MCPServerSpec `yaml:"mcp,omitempty"` +} + ``` +3. After the `ShouldSyncGeminiLogs` function, add: + ```go +// ShouldSyncKiroLogs returns true if Kiro session logs should be synced. +// - If kiro.sync_logs is explicitly set, use that value +// - Otherwise, enable sync_logs if "kiro" is in grants +func (c *Config) ShouldSyncKiroLogs() bool { + if c.Kiro.SyncLogs != nil { + return *c.Kiro.SyncLogs + } + for _, grant := range c.Grants { + if grant == "kiro" || strings.HasPrefix(grant, "kiro:") { + return true + } + } + return false +} + ``` +4. In the validation function, after the Gemini MCP validation loop, add: + ```go + // Validate Kiro MCP server specs + for name, spec := range cfg.Kiro.MCP { + if err := validateMCPServerSpec("kiro", name, spec); err != nil { + return nil, err + } + } + ``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/config/ -run TestKiroConfigParsesAndSyncLogs -v && go test ./internal/config/` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +go vet ./internal/config/ +git add internal/config/config.go internal/config/config_test.go +git commit -m "feat(config): add kiro config section and log sync" +``` + +--- + +### Task 7: Kiro CLI command + +**Files:** +- Modify: `internal/providers/kiro/cli.go` (replace the Task 3 stub) +- Test: `internal/providers/kiro/cli_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/providers/kiro/cli_test.go`: + +```go +package kiro + +import ( + "slices" + "testing" + + "github.com/spf13/cobra" +) + +func TestNetworkHosts(t *testing.T) { + hosts := NetworkHosts() + for _, want := range []string{"q.*.amazonaws.com", "cognito-identity.*.amazonaws.com", "cli.kiro.dev"} { + if !slices.Contains(hosts, want) { + t.Errorf("NetworkHosts() missing %q: %v", want, hosts) + } + } +} + +func TestDefaultDependencies(t *testing.T) { + deps := DefaultDependencies() + if !slices.Contains(deps, "kiro-cli") || !slices.Contains(deps, "git") { + t.Errorf("DefaultDependencies() = %v, want kiro-cli and git", deps) + } +} + +func TestRegisterCLIAddsKiroCommand(t *testing.T) { + root := &cobra.Command{Use: "moat"} + (&Provider{}).RegisterCLI(root) + found := false + for _, c := range root.Commands() { + if c.Name() == "kiro" { + found = true + } + } + if !found { + t.Error("RegisterCLI did not add 'kiro' command") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/providers/kiro/ -run 'TestNetworkHosts|TestDefaultDependencies|TestRegisterCLI' -v` +Expected: FAIL (undefined: NetworkHosts/DefaultDependencies; stub adds no command). + +- [ ] **Step 3: Implement the CLI command** + +Replace the entire contents of `internal/providers/kiro/cli.go` with: + +```go +package kiro + +import ( + "github.com/spf13/cobra" + + "github.com/majorcontext/moat/internal/cli" + "github.com/majorcontext/moat/internal/config" + "github.com/majorcontext/moat/internal/credential" +) + +var ( + kiroFlags cli.ExecFlags + kiroPromptFlag string + kiroAllowedHosts []string + kiroWtFlag string +) + +// NetworkHosts returns the hosts Kiro needs network access to. +func NetworkHosts() []string { + hosts := []string{"cli.kiro.dev"} + hosts = append(hosts, kiroAPIHosts...) + hosts = append(hosts, kiroPassthroughHosts...) + return hosts +} + +// DefaultDependencies returns the default dependencies for running Kiro CLI. +func DefaultDependencies() []string { + return []string{"kiro-cli", "git"} +} + +// RegisterCLI registers the `moat kiro` command. Called automatically for +// every AgentProvider by cmd/moat/cli/root.go. +func (p *Provider) RegisterCLI(root *cobra.Command) { + kiroCmd := &cobra.Command{ + Use: "kiro [workspace] [flags]", + Short: "Run Kiro CLI in an isolated container", + Long: `Run the Kiro CLI in an isolated container with automatic credential injection. + +Your workspace is mounted at /workspace inside the container. Kiro credentials +are injected transparently via the Moat proxy - Kiro never sees raw tokens. + +Examples: + # Start Kiro in the current directory (interactive) + moat kiro + + # Start Kiro in a specific project + moat kiro ./my-project + + # Ask Kiro to do something specific (non-interactive) + moat kiro -p "explain this codebase" + + # Add additional grants (e.g., for GitHub API access) + moat kiro --grant github + +Use 'moat list' to see running and recent runs.`, + Args: cobra.ArbitraryArgs, + RunE: runKiro, + } + + cli.AddExecFlags(kiroCmd, &kiroFlags) + kiroCmd.Flags().StringVarP(&kiroPromptFlag, "prompt", "p", "", "run with prompt (non-interactive mode)") + kiroCmd.Flags().StringSliceVar(&kiroAllowedHosts, "allow-host", nil, "additional hosts to allow network access to") + kiroCmd.Flags().StringVar(&kiroWtFlag, "worktree", "", "run in a git worktree for this branch") + kiroCmd.Flags().StringVar(&kiroWtFlag, "wt", "", "alias for --worktree") + _ = kiroCmd.Flags().MarkHidden("wt") + + root.AddCommand(kiroCmd) +} + +func runKiro(cmd *cobra.Command, args []string) error { + return cli.RunProvider(cmd, args, cli.ProviderRunConfig{ + Name: "kiro", + Flags: &kiroFlags, + PromptFlag: kiroPromptFlag, + AllowedHosts: kiroAllowedHosts, + WtFlag: kiroWtFlag, + GetCredentialGrant: GetCredentialName, + Dependencies: DefaultDependencies(), + NetworkHosts: NetworkHosts(), + SupportsInitialPrompt: true, + DryRunNote: "Note: No Kiro token configured. Run 'moat grant kiro'.", + BuildCommand: func(promptFlag, initialPrompt string) ([]string, error) { + containerCmd := []string{"kiro-cli", "chat", "--trust-all-tools", "--trust-tools=execute_bash"} + if promptFlag != "" { + return append(containerCmd, "--no-interactive", promptFlag), nil + } + if initialPrompt != "" { + containerCmd = append(containerCmd, initialPrompt) + } + return containerCmd, nil + }, + ConfigureAgent: func(cfg *config.Config) { + syncLogs := true + cfg.Kiro.SyncLogs = &syncLogs + }, + }) +} + +// GetCredentialName returns "kiro" if a Kiro credential exists, else "". +func GetCredentialName() string { + key, err := credential.DefaultEncryptionKey() + if err != nil { + return "" + } + store, err := credential.NewFileStore(credential.DefaultStoreDir(), key) + if err != nil { + return "" + } + if _, err := store.Get(credential.ProviderKiro); err == nil { + return "kiro" + } + return "" +} +``` + +`cfg.Kiro` was added in Task 6, so this file compiles directly. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/providers/kiro/ -v` +Expected: PASS (all kiro tests). + +- [ ] **Step 5: Commit** + +```bash +go vet ./internal/providers/kiro/ +git add internal/providers/kiro/cli.go internal/providers/kiro/cli_test.go +git commit -m "feat(kiro): add 'moat kiro' command" +``` + +--- + +### Task 8: Register the kiro provider + +**Files:** +- Modify: `internal/providers/register.go` + +- [ ] **Step 1: Add the import** + +In `internal/providers/register.go`, add to the import block (keep alphabetical-ish grouping, place after the `gemini` line and before `github`): + +```go + _ "github.com/majorcontext/moat/internal/providers/kiro" // registers Kiro provider +``` + +- [ ] **Step 2: Verify registration via a test** + +Add to `internal/providers/register_test.go` (create the file if it does not exist): + +```go +package providers + +import ( + "testing" + + "github.com/majorcontext/moat/internal/provider" +) + +func TestKiroProviderRegistered(t *testing.T) { + if provider.Get("kiro") == nil { + t.Fatal("kiro provider not registered") + } + if provider.GetAgent("kiro") == nil { + t.Fatal("kiro provider does not implement AgentProvider") + } +} +``` + +- [ ] **Step 3: Run test to verify it passes** + +Run: `go test ./internal/providers/ -run TestKiroProviderRegistered -v` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +go vet ./internal/providers/ +git add internal/providers/register.go internal/providers/register_test.go +git commit -m "feat(kiro): register provider" +``` + +--- + +### Task 9: Image-needs detection for kiro init + +**Files:** +- Modify: `internal/run/imageneeds.go` (add `case "kiro":` + dependency fallback) +- Test: `internal/run/imageneeds_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `internal/run/imageneeds_test.go` (mirror the existing codex/gemini detection tests in that file — find one named like `TestResolveImageNeeds*` and copy its store-mock setup): + +```go +func TestResolveImageNeedsKiro(t *testing.T) { + store := newTestStore(t) // use whatever helper the existing codex test uses + _ = store.Save(credential.Credential{Provider: credential.ProviderKiro, Token: "t", CreatedAt: time.Now()}) + + needs := resolveImageNeedsWithStore([]string{"kiro"}, nil, store) + if !slices.Contains(needs.initProviders, "kiro") { + t.Errorf("initProviders = %v, want to contain kiro", needs.initProviders) + } +} + +func TestResolveImageNeedsKiroDepFallback(t *testing.T) { + needs := resolveImageNeedsWithStore(nil, []deps.Dependency{{Name: "kiro-cli"}}, nil) + if !slices.Contains(needs.initProviders, "kiro") { + t.Errorf("initProviders = %v, want to contain kiro (dep fallback)", needs.initProviders) + } +} +``` + +If the existing tests use a different store-construction helper or `deps.Dependency` literal shape, match that exactly (read a neighboring test first). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/run/ -run TestResolveImageNeedsKiro -v` +Expected: FAIL (no "kiro" in initProviders). + +- [ ] **Step 3: Add the detection case and fallback** + +In `internal/run/imageneeds.go`, in `resolveImageNeedsWithStore`: + +1. In the `switch canonical` block, after the `case "gemini":` block, add: + ```go + case "kiro": + if store != nil { + if _, err := store.Get(credential.ProviderKiro); err == nil { + initSet["kiro"] = true + } + } + ``` +2. After the existing `if !initSet["gemini"] && hasDep(depList, "gemini-cli")` block, add: + ```go + if !initSet["kiro"] && hasDep(depList, "kiro-cli") { + initSet["kiro"] = true + } + ``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/run/ -run TestResolveImageNeedsKiro -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +go vet ./internal/run/ +git add internal/run/imageneeds.go internal/run/imageneeds_test.go +git commit -m "feat(run): detect kiro init from grant or kiro-cli dependency" +``` + +--- + +### Task 10: Run-manager dispatch block + +**Files:** +- Modify: `internal/run/manager.go` (add `needsKiroInit`, kiro `PrepareContainer` block, grant maps, cleanup) +- Test: covered by build + existing manager tests + `go test ./...` + +- [ ] **Step 1: Add `needsKiroInit`** + +In `internal/run/manager.go`, next to: +```go + needsGeminiInit := slices.Contains(imgNeeds.initProviders, "gemini") +``` +add: +```go + needsKiroInit := slices.Contains(imgNeeds.initProviders, "kiro") +``` + +- [ ] **Step 2: Extend grant→env maps for kiro MCP child processes** + +In `grantToEnvVar`, add before `default:`: +```go + case "kiro": + return "KIRO_API_KEY", true +``` +In `grantToPlaceholder`, add before `default:`: +```go + case "kiro": + return kiro.KiroAPIKeyPlaceholder +``` +Add the import `"github.com/majorcontext/moat/internal/providers/kiro"` to `manager.go`'s import block. Also update the error message in the codex local-MCP block that lists supported grants (`"... supported: github, openai, anthropic, gemini"`) to include `kiro` if a kiro MCP block reuses that validation path; otherwise leave codex's message untouched and rely on the kiro block's own message (Step 3). + +- [ ] **Step 3: Add the kiro PrepareContainer dispatch block** + +Locate the Gemini dispatch block (search for `needsGeminiInit ||` and its closing — it ends right before the section that assembles the final container spec). Immediately after the Gemini block's closing brace, add the following, structured identically to the Codex block (search `needsCodexInit || hasCodexLocalMCP` to copy its exact structure including the `grantToEnvVar`/`hasGrant` handling and the `cleanupAgentConfig` chaining): + +```go + // Set up Kiro staging directory for init script. + var kiroConfig *provider.ContainerConfig + hasKiroLocalMCP := opts.Config != nil && len(opts.Config.Kiro.MCP) > 0 + if needsKiroInit || hasKiroLocalMCP || (opts.Config != nil && opts.Config.ShouldSyncKiroLogs()) { + kiroProvider := provider.GetAgent("kiro") + if kiroProvider == nil { + cleanupDaemonRun() + cleanupSSH(sshServer) + cleanupAgentConfig(claudeConfig) + cleanupAgentConfig(codexConfig) + cleanupAgentConfig(geminiConfig) + return nil, fmt.Errorf("kiro provider not registered") + } + + // Kiro credential (stored under "kiro"). + var kiroCred *provider.Credential + if needsKiroInit { + key, keyErr := credential.DefaultEncryptionKey() + if keyErr == nil { + store, storeErr := credential.NewFileStore(credential.DefaultStoreDir(), key) + if storeErr == nil { + if cred, err := store.Get(credential.ProviderKiro); err == nil { + kiroCred = provider.FromLegacy(cred) + } + } + } + } + + // Remote MCP relay map (proxy relay URLs), same as the Claude block. + kiroMCPServers := make(map[string]provider.MCPServerConfig) + if opts.Config != nil && len(opts.Config.MCP) > 0 { + proxyAddr := fmt.Sprintf("%s:%d", m.defaultRuntime().GetHostAddress(), r.ProxyPort) + for _, mcp := range opts.Config.MCP { + relayURL := fmt.Sprintf("http://%s/mcp/%s/%s", proxyAddr, r.ProxyAuthToken, mcp.Name) + mc := provider.MCPServerConfig{URL: relayURL} + if mcp.Auth != nil { + mc.Headers = map[string]string{mcp.Auth.Header: "moat-stub-" + mcp.Auth.Grant} + } + kiroMCPServers[mcp.Name] = mc + } + } + + // Local MCP servers from kiro.mcp (with grant→placeholder-env, same + // handling as the Codex block). + var kiroLocalMCP map[string]provider.LocalMCPServerConfig + if opts.Config != nil && len(opts.Config.Kiro.MCP) > 0 { + kiroLocalMCP = make(map[string]provider.LocalMCPServerConfig) + for name, spec := range opts.Config.Kiro.MCP { + env := spec.Env + if spec.Grant != "" { + v, ok := grantToEnvVar(spec.Grant) + if !ok { + cleanupDaemonRun() + cleanupSSH(sshServer) + cleanupAgentConfig(claudeConfig) + cleanupAgentConfig(codexConfig) + cleanupAgentConfig(geminiConfig) + return nil, fmt.Errorf("kiro.mcp.%s: unknown grant %q (supported: github, openai, anthropic, gemini, kiro)", name, spec.Grant) + } + if !hasGrant(opts.Config.Grants, spec.Grant) { + cleanupDaemonRun() + cleanupSSH(sshServer) + cleanupAgentConfig(claudeConfig) + cleanupAgentConfig(codexConfig) + cleanupAgentConfig(geminiConfig) + return nil, fmt.Errorf("kiro.mcp.%s: grant %q not declared in top-level grants list — add 'grants: [%s]' to agent.yaml", name, spec.Grant, spec.Grant) + } + if env == nil { + env = make(map[string]string) + } else { + envCopy := make(map[string]string, len(env)+1) + for k, val := range env { + envCopy[k] = val + } + env = envCopy + } + env[v] = grantToPlaceholder(spec.Grant) + } + kiroLocalMCP[name] = provider.LocalMCPServerConfig{ + Command: spec.Command, + Args: spec.Args, + Env: env, + Cwd: spec.Cwd, + } + } + } + + var prepErr error + kiroConfig, prepErr = kiroProvider.PrepareContainer(ctx, provider.PrepareOpts{ + Credential: kiroCred, + ContainerHome: containerHome, + MCPServers: kiroMCPServers, + RuntimeContext: renderedContext, + LocalMCPServers: kiroLocalMCP, + }) + if prepErr != nil { + cleanupDaemonRun() + cleanupSSH(sshServer) + cleanupAgentConfig(claudeConfig) + cleanupAgentConfig(codexConfig) + cleanupAgentConfig(geminiConfig) + return nil, fmt.Errorf("preparing Kiro container config: %w", prepErr) + } + + mounts = append(mounts, kiroConfig.Mounts...) + proxyEnv = append(proxyEnv, kiroConfig.Env...) + } +``` + +**IMPORTANT:** the exact variable names (`codexConfig`, `geminiConfig`, `cleanupAgentConfig`, `cleanupSSH`, `sshServer`, `containerHome`, `renderedContext`, `proxyEnv`, `mounts`, `m.defaultRuntime()`, `r.ProxyPort`, `r.ProxyAuthToken`) must match what the Codex/Gemini blocks actually use at that point in `manager.go`. Read the Codex block (search `needsCodexInit || hasCodexLocalMCP`) and the Gemini block first and copy their exact cleanup-chain and helper calls. Then find where `codexConfig`/`geminiConfig` get added to the global `cleanupAgentConfig` teardown path (search `cleanupAgentConfig(codexConfig)`) and add `cleanupAgentConfig(kiroConfig)` everywhere `cleanupAgentConfig(geminiConfig)` appears after this block. + +- [ ] **Step 4: Verify wildcard credential injection assumption** + +Confirm gatekeeper v0.2.0 matches wildcard host patterns for credential injection. Run: + +```bash +go doc github.com/majorcontext/gatekeeper/proxy 2>/dev/null | head -40 +grep -rn "filepath.Match\|HasSuffix\|wildcard\|\\*\\." "$(go env GOMODCACHE)"/github.com/majorcontext/gatekeeper@*/proxy/*.go 2>/dev/null | grep -iv test | head +``` + +- If wildcard matching is supported: leave `kiroAPIHosts` as-is. +- If only exact hosts match: in `internal/providers/kiro/constants.go`, replace `kiroAPIHosts` with `[]string{"q.us-east-1.amazonaws.com"}` and add a code comment + a docs note (Task 11) explaining how to add other regions. Update `provider_test.go`'s expected hosts accordingly. Re-run `go test ./internal/providers/kiro/`. + +Record the outcome in the commit message. + +- [ ] **Step 5: Build and test** + +Run: `go build ./... && go test ./internal/run/ ./internal/providers/kiro/` +Expected: PASS / build clean. + +- [ ] **Step 6: Commit** + +```bash +make lint || go vet ./... +git add internal/run/manager.go internal/providers/kiro/ +git commit -m "feat(run): wire kiro PrepareContainer dispatch (incl. local + remote MCP)" +``` + +--- + +### Task 11: Documentation + changelog + +**Files:** +- Modify: `docs/content/reference/01-cli.md` +- Modify: `docs/content/reference/02-moat-yaml.md` +- Create: `docs/content/guides/-kiro.md` (use the next free number; mirror an existing agent guide such as the codex guide) +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: CLI reference** + +In `docs/content/reference/01-cli.md`, find the section documenting `moat codex` / `moat grant codex` and add parallel entries for: +- `moat kiro [workspace] [flags]` — flags `-p/--prompt`, `--allow-host`, `--worktree/--wt`, plus the shared exec flags; one-line description matching the command's `Short`. +- `moat grant kiro` — prompts for a Kiro token (or reads `KIRO_API_KEY`); static credential, re-grant when it expires. + +Match the surrounding formatting exactly (verify by reading the codex entry first). + +- [ ] **Step 2: moat.yaml reference** + +In `docs/content/reference/02-moat-yaml.md`, find the `codex:` section and add a parallel `kiro:` section documenting `sync_logs` and `mcp:` (local MCP server specs), noting `agent: kiro` and `grants: [kiro]`. + +- [ ] **Step 3: Guide** + +Create the guide (next free `docs/content/guides/-kiro.md`), mirroring the structure of the existing codex guide: prerequisites (`moat grant kiro`), quick start (`moat kiro`), MCP config example, and the network hosts Kiro uses. State facts only (per STYLE-GUIDE). + +- [ ] **Step 4: Changelog** + +In `CHANGELOG.md`, under the unreleased/next version's `### Added` heading (create the version stanza if absent, following the existing format), add: + +```markdown +- **Kiro CLI support** — `moat kiro` runs the Kiro CLI in an isolated container with transparent credential injection; `moat grant kiro` stores a Kiro token. Supports local and remote MCP and runtime-context injection. ([#NNN](https://github.com/majorcontext/moat/pull/NNN)) +``` + +Leave `NNN` to be replaced with the real PR number when the PR is opened (this is the one place a number is unknown until PR creation — note it in the PR description). + +- [ ] **Step 5: Commit** + +```bash +git add docs/ CHANGELOG.md +git commit -m "docs: document kiro-cli support" +``` + +--- + +### Task 12: Full verification + +- [ ] **Step 1: Full build** + +Run: `go build ./...` +Expected: clean. + +- [ ] **Step 2: Full test suite with race detector** + +Run: `make test-unit` +Expected: PASS. If a pre-existing unrelated failure appears, capture the output and report it rather than masking it. + +- [ ] **Step 3: Lint** + +Run: `make lint` (fallback `go vet ./...`) +Expected: clean. Fix any kiro-related findings. + +- [ ] **Step 4: Manual smoke (dry run, no real container)** + +Run: `go run ./cmd/moat kiro --help` and `go run ./cmd/moat grant --help` +Expected: `kiro` command and `grant kiro` listed; help text renders. + +- [ ] **Step 5: Final commit (if any lint fixes)** + +```bash +make lint || go vet ./... +git add -A +git commit -m "chore(kiro): lint and final cleanup" +``` + +- [ ] **Step 6: Hand back to the operator** + +Summarize: tasks completed, the wildcard-injection verification outcome (Task 10 Step 4), the MCP-JSON-shape confirmation (Task 5), and any deviations. Do NOT open a PR unless the operator asks (per CLAUDE.md, use `gh pr create` with default flags only if requested). + +--- + +## Self-Review + +**Spec coverage:** +- Install (spec §1) → Task 1 ✓ +- Credential provider: provider.go/constants.go/grant.go (spec §2) → Tasks 2,3,4 ✓ +- Hosts table (spec §2) → Task 3 constants + Task 10 Step 4 verification ✓ +- Agent staging: cli.json/mcp.json/default.json/steering (spec §3) → Task 5 ✓ +- Config section + ShouldSyncKiroLogs (spec §4) → Task 6 ✓ +- Run wiring incl. remote+local MCP, cleanup (spec §5) → Tasks 9,10 ✓ +- `moat kiro` command (spec §6) → Task 7 ✓ +- Registration + ProviderKiro constant (spec §7) → Tasks 2,8 ✓ +- Docs + changelog (spec §8) → Task 11 ✓ +- Verification points (spec §Verification) → Task 5 header (MCP shape), Task 10 Step 4 (wildcards), `~/.kiro` layout noted in Task 5 ✓ +- Out-of-scope items: none implemented ✓ + +**Placeholder scan:** No TBD/TODO. The single unavoidable unknown (`#NNN` PR number in CHANGELOG) is explicitly called out with handling. Verification points are concrete steps with commands and decision branches, not placeholders. + +**Type consistency:** `Provider`, `NewGrant()`/`Grant.Execute`, `KiroAPIKeyPlaceholder`, `KiroInitMountPath`, `kiroAPIHosts`, `kiroPassthroughHosts`, `NetworkHosts()`, `DefaultDependencies()`, `GetCredentialName()`, `credential.ProviderKiro`, `config.KiroConfig`/`cfg.Kiro`/`ShouldSyncKiroLogs()`, `needsKiroInit`, `kiroConfig` — used consistently across Tasks 2–10. + +**Cross-task ordering note:** Tasks are ordered so each compiles when executed in sequence. Key dependency: `internal/providers/kiro/cli.go` (Task 7) references `cfg.Kiro`, which is why the config section is Task 6 (before it). Task 8 (register) requires the kiro package to compile, which it does after Tasks 3–7. Execute tasks strictly in ascending order. From 10d9d8c658a94d49815c8d76fc42f5ccbe282c65 Mon Sep 17 00:00:00 2001 From: Nate Hardison Date: Tue, 19 May 2026 16:58:23 +0000 Subject: [PATCH 04/15] feat(deps): register kiro-cli dependency Add kiro-cli to the dependency registry with custom install type. The Kiro CLI uses a native installer from cli.kiro.dev that places the binary in ~/.local/bin. --- internal/deps/install.go | 11 +++++++++++ internal/deps/install_test.go | 14 ++++++++++++++ internal/deps/registry.yaml | 5 +++++ 3 files changed, 30 insertions(+) diff --git a/internal/deps/install.go b/internal/deps/install.go index 9af76b10..55e612c0 100644 --- a/internal/deps/install.go +++ b/internal/deps/install.go @@ -372,6 +372,17 @@ func getCustomCommands(name, version string) InstallCommands { "PATH": "/home/moatuser/.claude/local/bin:/home/moatuser/.local/bin:$PATH", }, } + case "kiro-cli": + // Native installer from cli.kiro.dev; binary lands in ~/.local/bin. + // --force skips the already-installed check for reproducible image builds. + return InstallCommands{ + Commands: []string{ + `curl -fsSL https://cli.kiro.dev/install | bash -s -- --force`, + }, + EnvVars: map[string]string{ + "PATH": "/home/moatuser/.local/bin:$PATH", + }, + } case "protoc": // Install protoc with well-known types (include directory). // The protoc zip contains bin/protoc and include/google/protobuf/*.proto. diff --git a/internal/deps/install_test.go b/internal/deps/install_test.go index 5c229c7e..ecbe5056 100644 --- a/internal/deps/install_test.go +++ b/internal/deps/install_test.go @@ -282,6 +282,20 @@ func TestGetCustomCommandsUnknown(t *testing.T) { } } +func TestGetCustomCommandsKiroCLI(t *testing.T) { + cmds := getCustomCommands("kiro-cli", "") + if len(cmds.Commands) != 1 { + t.Fatalf("expected 1 command, got %d: %v", len(cmds.Commands), cmds.Commands) + } + want := "curl -fsSL https://cli.kiro.dev/install | bash -s -- --force" + if cmds.Commands[0] != want { + t.Errorf("command = %q, want %q", cmds.Commands[0], want) + } + if got := cmds.EnvVars["PATH"]; got != "/home/moatuser/.local/bin:$PATH" { + t.Errorf("PATH = %q, want %q", got, "/home/moatuser/.local/bin:$PATH") + } +} + func TestGetDynamicPackageCommands(t *testing.T) { tests := []struct { dep Dependency diff --git a/internal/deps/registry.yaml b/internal/deps/registry.yaml index 107deeee..90810c01 100644 --- a/internal/deps/registry.yaml +++ b/internal/deps/registry.yaml @@ -93,6 +93,11 @@ gemini-cli: package: "@google/gemini-cli" requires: [node] +kiro-cli: + description: Amazon Kiro AI coding assistant + type: custom + user-install: true + graphite-cli: description: Graphite CLI for stacked PRs type: npm From fc0ca2e6137540f72395628e2ec37ead87e13c75 Mon Sep 17 00:00:00 2001 From: Nate Hardison Date: Tue, 19 May 2026 17:03:15 +0000 Subject: [PATCH 05/15] feat(credential): add ProviderKiro constant --- internal/credential/types.go | 5 +++-- internal/credential/types_test.go | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 internal/credential/types_test.go diff --git a/internal/credential/types.go b/internal/credential/types.go index e2db59e9..dffae1a0 100644 --- a/internal/credential/types.go +++ b/internal/credential/types.go @@ -24,6 +24,7 @@ const ( ProviderNpm Provider = "npm" ProviderGraphite Provider = "graphite" ProviderMeta Provider = "meta" + ProviderKiro Provider = "kiro" ) // Credential represents a stored credential. @@ -55,14 +56,14 @@ func RegisterDynamicProvider(p Provider) { // KnownProviders returns a list of all known credential providers. func KnownProviders() []Provider { - base := []Provider{ProviderGitHub, ProviderAWS, ProviderAnthropic, ProviderClaude, ProviderOpenAI, ProviderGemini, ProviderNpm, ProviderGraphite, ProviderMeta} + base := []Provider{ProviderGitHub, ProviderAWS, ProviderAnthropic, ProviderClaude, ProviderOpenAI, ProviderGemini, ProviderNpm, ProviderGraphite, ProviderMeta, ProviderKiro} return append(base, dynamicProviders...) } // IsKnownProvider returns true if the provider is a known credential provider. func IsKnownProvider(p Provider) bool { switch p { - case ProviderGitHub, ProviderAWS, ProviderAnthropic, ProviderClaude, ProviderOpenAI, ProviderGemini, ProviderNpm, ProviderGraphite, ProviderMeta: + case ProviderGitHub, ProviderAWS, ProviderAnthropic, ProviderClaude, ProviderOpenAI, ProviderGemini, ProviderNpm, ProviderGraphite, ProviderMeta, ProviderKiro: return true default: for _, dp := range dynamicProviders { diff --git a/internal/credential/types_test.go b/internal/credential/types_test.go new file mode 100644 index 00000000..3397d7a7 --- /dev/null +++ b/internal/credential/types_test.go @@ -0,0 +1,21 @@ +package credential + +import "testing" + +func TestProviderKiroIsKnown(t *testing.T) { + if ProviderKiro != "kiro" { + t.Errorf("ProviderKiro = %q, want %q", ProviderKiro, "kiro") + } + if !IsKnownProvider(ProviderKiro) { + t.Error("IsKnownProvider(ProviderKiro) = false, want true") + } + found := false + for _, p := range KnownProviders() { + if p == ProviderKiro { + found = true + } + } + if !found { + t.Error("KnownProviders() does not include ProviderKiro") + } +} From 731c6ef542307fc18148fcd2f4fbc59a3b1f7dad Mon Sep 17 00:00:00 2001 From: Nate Hardison Date: Tue, 19 May 2026 17:07:16 +0000 Subject: [PATCH 06/15] feat(kiro): add credential provider (proxy injection + container env) --- internal/providers/kiro/agent.go | 13 +++++ internal/providers/kiro/cli.go | 6 ++ internal/providers/kiro/constants.go | 26 +++++++++ internal/providers/kiro/doc.go | 6 ++ internal/providers/kiro/grant.go | 17 ++++++ internal/providers/kiro/provider.go | 54 ++++++++++++++++++ internal/providers/kiro/provider_test.go | 73 ++++++++++++++++++++++++ 7 files changed, 195 insertions(+) create mode 100644 internal/providers/kiro/agent.go create mode 100644 internal/providers/kiro/cli.go create mode 100644 internal/providers/kiro/constants.go create mode 100644 internal/providers/kiro/doc.go create mode 100644 internal/providers/kiro/grant.go create mode 100644 internal/providers/kiro/provider.go create mode 100644 internal/providers/kiro/provider_test.go diff --git a/internal/providers/kiro/agent.go b/internal/providers/kiro/agent.go new file mode 100644 index 00000000..0fbf1e79 --- /dev/null +++ b/internal/providers/kiro/agent.go @@ -0,0 +1,13 @@ +package kiro + +import ( + "context" + "errors" + + "github.com/majorcontext/moat/internal/provider" +) + +// PrepareContainer is implemented in Task 5. +func (p *Provider) PrepareContainer(ctx context.Context, opts provider.PrepareOpts) (*provider.ContainerConfig, error) { + return nil, errors.New("not implemented") +} diff --git a/internal/providers/kiro/cli.go b/internal/providers/kiro/cli.go new file mode 100644 index 00000000..9fbb2d5f --- /dev/null +++ b/internal/providers/kiro/cli.go @@ -0,0 +1,6 @@ +package kiro + +import "github.com/spf13/cobra" + +// RegisterCLI is implemented in Task 6. +func (p *Provider) RegisterCLI(root *cobra.Command) {} diff --git a/internal/providers/kiro/constants.go b/internal/providers/kiro/constants.go new file mode 100644 index 00000000..d67072a6 --- /dev/null +++ b/internal/providers/kiro/constants.go @@ -0,0 +1,26 @@ +package kiro + +// KiroAPIKeyPlaceholder is a syntactically plausible placeholder API key. +// kiro-cli runs in API-key mode when KIRO_API_KEY is set and sends this +// value as a Bearer token; the Moat proxy replaces it with the real token +// at the network layer. The real token never enters the container. +const KiroAPIKeyPlaceholder = "kiro-moat-proxy-injected-placeholder-000000000000000000000000000000" + +// KiroInitMountPath is where the staging directory is mounted in containers. +const KiroInitMountPath = "/moat/kiro-init" + +// kiroAPIHosts are the hosts the proxy injects the Kiro Bearer token for. +// +// VERIFICATION POINT (spec §Verification 3): if gatekeeper v0.2.0 does not +// match wildcard host patterns for credential injection, replace this slice +// with the single concrete host "q.us-east-1.amazonaws.com" and document how +// to add other regions. Confirm during implementation (see Task 10 Step "verify"). +var kiroAPIHosts = []string{ + "q.*.amazonaws.com", + "*.q.*.amazonaws.com", +} + +// kiroPassthroughHosts are allowlisted but receive no credential injection. +var kiroPassthroughHosts = []string{ + "cognito-identity.*.amazonaws.com", +} diff --git a/internal/providers/kiro/doc.go b/internal/providers/kiro/doc.go new file mode 100644 index 00000000..1a80ed62 --- /dev/null +++ b/internal/providers/kiro/doc.go @@ -0,0 +1,6 @@ +// Package kiro implements the Kiro CLI agent provider for Moat. +// +// It mirrors the codex provider: a placeholder KIRO_API_KEY is set in the +// container while the Moat proxy injects the real Bearer token on the Kiro +// API hosts. Container config is staged and copied to ~/.kiro by moat-init. +package kiro diff --git a/internal/providers/kiro/grant.go b/internal/providers/kiro/grant.go new file mode 100644 index 00000000..bccfffc6 --- /dev/null +++ b/internal/providers/kiro/grant.go @@ -0,0 +1,17 @@ +package kiro + +import ( + "context" + "errors" + + "github.com/majorcontext/moat/internal/provider" +) + +// Grant is implemented in Task 4. +type Grant struct{} + +func NewGrant() *Grant { return &Grant{} } + +func (g *Grant) Execute(ctx context.Context) (*provider.Credential, error) { + return nil, errors.New("not implemented") +} diff --git a/internal/providers/kiro/provider.go b/internal/providers/kiro/provider.go new file mode 100644 index 00000000..0dcb772e --- /dev/null +++ b/internal/providers/kiro/provider.go @@ -0,0 +1,54 @@ +package kiro + +import ( + "context" + + "github.com/majorcontext/moat/internal/provider" +) + +// Provider implements provider.CredentialProvider and provider.AgentProvider +// for the Kiro CLI. +type Provider struct{} + +var ( + _ provider.CredentialProvider = (*Provider)(nil) + _ provider.AgentProvider = (*Provider)(nil) +) + +func init() { + provider.Register(&Provider{}) +} + +// Name returns the provider identifier. +func (p *Provider) Name() string { return "kiro" } + +// Grant acquires a Kiro token interactively or from environment. +func (p *Provider) Grant(ctx context.Context) (*provider.Credential, error) { + return NewGrant().Execute(ctx) +} + +// ConfigureProxy injects the real Kiro Bearer token on the Kiro API hosts. +// Passthrough hosts receive no injection (they are only allowlisted). +func (p *Provider) ConfigureProxy(proxy provider.ProxyConfigurer, cred *provider.Credential) { + for _, host := range kiroAPIHosts { + proxy.SetCredentialWithGrant(host, "Authorization", "Bearer "+cred.Token, "kiro") + } +} + +// ContainerEnv sets a placeholder KIRO_API_KEY. kiro-cli runs in API-key +// mode and sends the placeholder; the proxy swaps in the real token. +func (p *Provider) ContainerEnv(cred *provider.Credential) []string { + return []string{"KIRO_API_KEY=" + KiroAPIKeyPlaceholder} +} + +// ContainerMounts returns no direct mounts — Kiro uses the staging-dir +// approach populated by PrepareContainer. +func (p *Provider) ContainerMounts(cred *provider.Credential, containerHome string) ([]provider.MountConfig, string, error) { + return nil, "", nil +} + +// Cleanup is a no-op; the staging directory is cleaned by the caller. +func (p *Provider) Cleanup(cleanupPath string) {} + +// ImpliedDependencies returns no implied dependencies. +func (p *Provider) ImpliedDependencies() []string { return nil } diff --git a/internal/providers/kiro/provider_test.go b/internal/providers/kiro/provider_test.go new file mode 100644 index 00000000..8f506294 --- /dev/null +++ b/internal/providers/kiro/provider_test.go @@ -0,0 +1,73 @@ +package kiro + +import ( + "testing" + + "github.com/majorcontext/moat/internal/provider" +) + +type mockProxyConfigurer struct { + headers map[string]map[string]string + grants map[string]string +} + +func newMockProxy() *mockProxyConfigurer { + return &mockProxyConfigurer{headers: make(map[string]map[string]string), grants: make(map[string]string)} +} + +func (m *mockProxyConfigurer) SetCredential(host, value string) {} +func (m *mockProxyConfigurer) SetCredentialHeader(host, h, v string) { + if m.headers[host] == nil { + m.headers[host] = map[string]string{} + } + m.headers[host][h] = v +} +func (m *mockProxyConfigurer) SetCredentialWithGrant(host, h, v, g string) { + if m.headers[host] == nil { + m.headers[host] = map[string]string{} + } + m.headers[host][h] = v + m.grants[host] = g +} +func (m *mockProxyConfigurer) AddExtraHeader(host, h, v string) {} +func (m *mockProxyConfigurer) AddResponseTransformer(host string, _ provider.ResponseTransformer) {} +func (m *mockProxyConfigurer) RemoveRequestHeader(host, h string) {} +func (m *mockProxyConfigurer) SetTokenSubstitution(host, p, r string) {} + +func TestProviderName(t *testing.T) { + if (&Provider{}).Name() != "kiro" { + t.Errorf("Name() = %q, want kiro", (&Provider{}).Name()) + } +} + +func TestConfigureProxyInjectsBearerOnKiroHosts(t *testing.T) { + m := newMockProxy() + (&Provider{}).ConfigureProxy(m, &provider.Credential{Token: "real-token"}) + for _, host := range kiroAPIHosts { + got := m.headers[host]["Authorization"] + if got != "Bearer real-token" { + t.Errorf("host %s Authorization = %q, want %q", host, got, "Bearer real-token") + } + if m.grants[host] != "kiro" { + t.Errorf("host %s grant = %q, want %q", host, m.grants[host], "kiro") + } + } + for _, host := range kiroPassthroughHosts { + if _, ok := m.headers[host]; ok { + t.Errorf("passthrough host %s should not receive credential injection", host) + } + } +} + +func TestContainerEnvSetsPlaceholder(t *testing.T) { + env := (&Provider{}).ContainerEnv(&provider.Credential{Token: "real"}) + want := "KIRO_API_KEY=" + KiroAPIKeyPlaceholder + if len(env) != 1 || env[0] != want { + t.Errorf("ContainerEnv() = %v, want [%q]", env, want) + } +} + +func TestInterfaceCompliance(t *testing.T) { + var _ provider.CredentialProvider = (*Provider)(nil) + var _ provider.AgentProvider = (*Provider)(nil) +} From 24997e7b1e0e70a7ca0b9bcafb25acf4fbf39868 Mon Sep 17 00:00:00 2001 From: Nate Hardison Date: Tue, 19 May 2026 17:12:29 +0000 Subject: [PATCH 07/15] feat(kiro): implement token grant flow --- internal/providers/kiro/grant.go | 66 +++++++++++++++++++++++++-- internal/providers/kiro/grant_test.go | 47 +++++++++++++++++++ 2 files changed, 108 insertions(+), 5 deletions(-) create mode 100644 internal/providers/kiro/grant_test.go diff --git a/internal/providers/kiro/grant.go b/internal/providers/kiro/grant.go index bccfffc6..9d8e9bec 100644 --- a/internal/providers/kiro/grant.go +++ b/internal/providers/kiro/grant.go @@ -2,16 +2,72 @@ package kiro import ( "context" - "errors" + "fmt" + "os" + "strings" + "time" + "github.com/majorcontext/moat/internal/credential" "github.com/majorcontext/moat/internal/provider" + "github.com/majorcontext/moat/internal/provider/util" ) -// Grant is implemented in Task 4. -type Grant struct{} +// Grant handles the Kiro token grant. It reads KIRO_API_KEY from the +// environment, falling back to an interactive prompt. The token is stored +// as-is and validated only by the upstream API at run time (no local +// validation endpoint — see spec). +type Grant struct { + // readToken reads a token interactively. Overridable in tests. + readToken func() (string, error) +} -func NewGrant() *Grant { return &Grant{} } +// NewGrant creates a Grant with the default interactive reader. The default +// uses the shared util.PromptForToken helper, which hides typed input on a +// TTY and falls back to a line read for piped input. +func NewGrant() *Grant { + return &Grant{ + readToken: func() (string, error) { + return util.PromptForToken("Enter your Kiro token") + }, + } +} +// Execute performs the grant. Returns a provider.Credential; the CLI wrapper +// persists it to the credential store. func (g *Grant) Execute(ctx context.Context) (*provider.Credential, error) { - return nil, errors.New("not implemented") + token := os.Getenv("KIRO_API_KEY") + if token != "" { + fmt.Println("Using token from KIRO_API_KEY environment variable") + } else { + var err error + token, err = g.readToken() + if err != nil { + return nil, fmt.Errorf("reading Kiro token: %w", err) + } + } + + token = strings.TrimSpace(token) + if token == "" { + return nil, fmt.Errorf("kiro token is empty") + } + + return &provider.Credential{ + Provider: "kiro", + Token: token, + CreatedAt: time.Now(), + }, nil +} + +// HasCredential reports whether a Kiro credential exists in the store. +func HasCredential() bool { + key, err := credential.DefaultEncryptionKey() + if err != nil { + return false + } + store, err := credential.NewFileStore(credential.DefaultStoreDir(), key) + if err != nil { + return false + } + cred, err := store.Get(credential.ProviderKiro) + return err == nil && cred != nil } diff --git a/internal/providers/kiro/grant_test.go b/internal/providers/kiro/grant_test.go new file mode 100644 index 00000000..16098231 --- /dev/null +++ b/internal/providers/kiro/grant_test.go @@ -0,0 +1,47 @@ +package kiro + +import ( + "context" + "strings" + "testing" +) + +func TestExecuteReadsEnv(t *testing.T) { + t.Setenv("KIRO_API_KEY", "env-token-123") + cred, err := NewGrant().Execute(context.Background()) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if cred.Token != "env-token-123" { + t.Errorf("Token = %q, want env-token-123", cred.Token) + } + if cred.Provider != "kiro" { + t.Errorf("Provider = %q, want kiro", cred.Provider) + } + if cred.CreatedAt.IsZero() { + t.Error("CreatedAt is zero") + } +} + +func TestExecutePromptsWhenNoEnv(t *testing.T) { + t.Setenv("KIRO_API_KEY", "") + g := NewGrant() + g.readToken = func() (string, error) { return " prompted-token ", nil } + cred, err := g.Execute(context.Background()) + if err != nil { + t.Fatalf("Execute() error = %v", err) + } + if cred.Token != "prompted-token" { + t.Errorf("Token = %q, want trimmed prompted-token", cred.Token) + } +} + +func TestExecuteEmptyTokenIsError(t *testing.T) { + t.Setenv("KIRO_API_KEY", "") + g := NewGrant() + g.readToken = func() (string, error) { return " ", nil } + _, err := g.Execute(context.Background()) + if err == nil || !strings.Contains(err.Error(), "empty") { + t.Fatalf("expected empty-token error, got %v", err) + } +} From ee89ffdd6c797367d242a2a5bf6cf47ee680d822 Mon Sep 17 00:00:00 2001 From: Nate Hardison Date: Tue, 19 May 2026 17:17:38 +0000 Subject: [PATCH 08/15] feat(kiro): stage ~/.kiro container config (settings, mcp, agent, steering) --- internal/providers/kiro/agent.go | 120 +++++++++++++++++++++++++- internal/providers/kiro/agent_test.go | 116 +++++++++++++++++++++++++ 2 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 internal/providers/kiro/agent_test.go diff --git a/internal/providers/kiro/agent.go b/internal/providers/kiro/agent.go index 0fbf1e79..2db08136 100644 --- a/internal/providers/kiro/agent.go +++ b/internal/providers/kiro/agent.go @@ -2,12 +2,126 @@ package kiro import ( "context" - "errors" + "encoding/json" + "fmt" + "os" + "path/filepath" "github.com/majorcontext/moat/internal/provider" ) -// PrepareContainer is implemented in Task 5. +// mcpLocalServer is a stdio MCP server entry in ~/.kiro/settings/mcp.json. +type mcpLocalServer struct { + Command string `json:"command"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` + Cwd string `json:"cwd,omitempty"` +} + +// mcpHTTPServer is a remote HTTP MCP server entry. kiro-cli supports remote +// HTTP MCP servers natively; key names follow +// https://kiro.dev/docs/cli/mcp/configuration/#remote-server. That site is +// not reachable from the moat sandbox, so the exact keys remain a documented +// verification point (design spec, Verification §2). +type mcpHTTPServer struct { + URL string `json:"url"` + Headers map[string]string `json:"headers,omitempty"` +} + +type mcpFile struct { + MCPServers map[string]any `json:"mcpServers"` +} + +// PrepareContainer stages a ~/.kiro tree (settings, agents, steering) that +// moat-init copies into the container. The real token is never written — +// auth is via the proxy. func (p *Provider) PrepareContainer(ctx context.Context, opts provider.PrepareOpts) (*provider.ContainerConfig, error) { - return nil, errors.New("not implemented") + tmpDir, err := os.MkdirTemp("", "moat-kiro-staging-*") + if err != nil { + return nil, fmt.Errorf("creating temp dir: %w", err) + } + cleanup := func() { os.RemoveAll(tmpDir) } + + for _, sub := range []string{"settings", "agents", "steering"} { + if mkErr := os.MkdirAll(filepath.Join(tmpDir, sub), 0o755); mkErr != nil { + cleanup() + return nil, fmt.Errorf("creating %s dir: %w", sub, mkErr) + } + } + + // settings/cli.json — allow --trust-all-tools non-interactively. + cliJSON, cErr := json.MarshalIndent(map[string]any{ + "chat.disableTrustAllConfirmation": true, + }, "", " ") + if cErr != nil { + cleanup() + return nil, fmt.Errorf("marshaling cli.json: %w", cErr) + } + if wErr := os.WriteFile(filepath.Join(tmpDir, "settings", "cli.json"), cliJSON, 0o600); wErr != nil { + cleanup() + return nil, fmt.Errorf("writing cli.json: %w", wErr) + } + + // settings/mcp.json — always written. agents/default.json sets + // includeMcpJson:true, so kiro-cli expects this file even when no servers + // are configured (unlike codex, whose base config lives in config.toml). + // An empty {"mcpServers":{}} is valid. + servers := map[string]any{} + for name, c := range opts.LocalMCPServers { + servers[name] = mcpLocalServer{Command: c.Command, Args: c.Args, Env: c.Env, Cwd: c.Cwd} + } + for name, c := range opts.MCPServers { + servers[name] = mcpHTTPServer{URL: c.URL, Headers: c.Headers} + } + mcpJSON, mErr := json.MarshalIndent(mcpFile{MCPServers: servers}, "", " ") + if mErr != nil { + cleanup() + return nil, fmt.Errorf("marshaling mcp.json: %w", mErr) + } + if wErr := os.WriteFile(filepath.Join(tmpDir, "settings", "mcp.json"), mcpJSON, 0o600); wErr != nil { + cleanup() + return nil, fmt.Errorf("writing mcp.json: %w", wErr) + } + + // agents/default.json — trimmed default agent including steering resources. + agentJSON, aErr := json.MarshalIndent(map[string]any{ + "name": "default", + "description": "Moat sandbox agent", + "tools": []string{"*"}, + "resources": []string{ + "file://README.md", + "file://AGENTS.md", + "file://.kiro/steering/**/*.md", + "file://~/.kiro/steering/**/*.md", + }, + "includeMcpJson": true, + }, "", " ") + if aErr != nil { + cleanup() + return nil, fmt.Errorf("marshaling default.json: %w", aErr) + } + if wErr := os.WriteFile(filepath.Join(tmpDir, "agents", "default.json"), agentJSON, 0o600); wErr != nil { + cleanup() + return nil, fmt.Errorf("writing default.json: %w", wErr) + } + + // steering/moat-context.md — runtime context (only when non-empty). + if opts.RuntimeContext != "" { + if wErr := os.WriteFile(filepath.Join(tmpDir, "steering", "moat-context.md"), []byte(opts.RuntimeContext), 0o644); wErr != nil { + cleanup() + return nil, fmt.Errorf("writing steering context: %w", wErr) + } + } + + env := p.ContainerEnv(opts.Credential) + env = append(env, "MOAT_KIRO_INIT="+KiroInitMountPath) + + return &provider.ContainerConfig{ + Env: env, + Mounts: []provider.MountConfig{ + {Source: tmpDir, Target: KiroInitMountPath, ReadOnly: true}, + }, + StagingDir: tmpDir, + Cleanup: cleanup, + }, nil } diff --git a/internal/providers/kiro/agent_test.go b/internal/providers/kiro/agent_test.go new file mode 100644 index 00000000..96e98427 --- /dev/null +++ b/internal/providers/kiro/agent_test.go @@ -0,0 +1,116 @@ +package kiro + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/majorcontext/moat/internal/provider" +) + +func readJSON(t *testing.T, path string) map[string]any { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + var m map[string]any + if err := json.Unmarshal(b, &m); err != nil { + t.Fatalf("unmarshal %s: %v", path, err) + } + return m +} + +func TestPrepareContainerWritesConfig(t *testing.T) { + p := &Provider{} + cfg, err := p.PrepareContainer(context.Background(), provider.PrepareOpts{ + Credential: &provider.Credential{Provider: "kiro", Token: "t"}, + ContainerHome: "/home/moatuser", + RuntimeContext: "# Moat context\nhello", + LocalMCPServers: map[string]provider.LocalMCPServerConfig{ + "local1": {Command: "mcp-local", Args: []string{"--x"}}, + }, + MCPServers: map[string]provider.MCPServerConfig{ + "remote1": {URL: "http://proxy/mcp/tok/remote1", Headers: map[string]string{"X-A": "b"}}, + }, + }) + if err != nil { + t.Fatalf("PrepareContainer() error = %v", err) + } + defer cfg.Cleanup() + + dir := cfg.StagingDir + + cli := readJSON(t, filepath.Join(dir, "settings", "cli.json")) + if cli["chat.disableTrustAllConfirmation"] != true { + t.Errorf("cli.json missing chat.disableTrustAllConfirmation=true: %v", cli) + } + + mcp := readJSON(t, filepath.Join(dir, "settings", "mcp.json")) + servers, ok := mcp["mcpServers"].(map[string]any) + if !ok { + t.Fatalf("mcp.json mcpServers not an object: %v", mcp) + } + if _, ok := servers["local1"]; !ok { + t.Error("mcp.json missing local1") + } + if _, ok := servers["remote1"]; !ok { + t.Error("mcp.json missing remote1") + } + + rawLocal, _ := json.Marshal(servers["local1"]) + if !strings.Contains(string(rawLocal), `"command"`) || strings.Contains(string(rawLocal), `"url"`) { + t.Errorf("local1 should have stdio shape (command, no url): %s", rawLocal) + } + rawRemote, _ := json.Marshal(servers["remote1"]) + if !strings.Contains(string(rawRemote), `"url"`) || strings.Contains(string(rawRemote), `"command"`) { + t.Errorf("remote1 should have HTTP shape (url, no command): %s", rawRemote) + } + + agentCfg := readJSON(t, filepath.Join(dir, "agents", "default.json")) + if agentCfg["includeMcpJson"] != true { + t.Errorf("default.json includeMcpJson != true: %v", agentCfg) + } + if res, ok := agentCfg["resources"].([]any); !ok || len(res) == 0 { + t.Errorf("default.json resources missing/empty: %v", agentCfg) + } + + ctx, err := os.ReadFile(filepath.Join(dir, "steering", "moat-context.md")) + if err != nil { + t.Fatalf("steering/moat-context.md: %v", err) + } + if string(ctx) != "# Moat context\nhello" { + t.Errorf("steering content = %q", string(ctx)) + } + + foundEnv := false + for _, e := range cfg.Env { + if e == "KIRO_API_KEY="+KiroAPIKeyPlaceholder { + foundEnv = true + } + } + if !foundEnv { + t.Errorf("env missing KIRO_API_KEY placeholder: %v", cfg.Env) + } + if len(cfg.Mounts) != 1 || cfg.Mounts[0].Target != KiroInitMountPath || !cfg.Mounts[0].ReadOnly { + t.Errorf("unexpected mounts: %+v", cfg.Mounts) + } +} + +func TestPrepareContainerOmitsEmptySteering(t *testing.T) { + p := &Provider{} + cfg, err := p.PrepareContainer(context.Background(), provider.PrepareOpts{ + Credential: &provider.Credential{Provider: "kiro", Token: "t"}, + ContainerHome: "/home/moatuser", + }) + if err != nil { + t.Fatalf("PrepareContainer() error = %v", err) + } + defer cfg.Cleanup() + if _, err := os.Stat(filepath.Join(cfg.StagingDir, "steering", "moat-context.md")); !os.IsNotExist(err) { + t.Errorf("steering file should not exist when RuntimeContext empty (err=%v)", err) + } +} From 82adc7ce6b505aa9fbf18a629333da521d6ef0be Mon Sep 17 00:00:00 2001 From: Nate Hardison Date: Tue, 19 May 2026 17:24:03 +0000 Subject: [PATCH 09/15] feat(config): add kiro config section and log sync --- internal/config/config.go | 33 +++++++++++++++++++++++++++++++++ internal/config/config_test.go | 21 +++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/internal/config/config.go b/internal/config/config.go index ce248527..a41190f0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -39,6 +39,7 @@ type Config struct { Claude ClaudeConfig `yaml:"claude,omitempty"` Codex CodexConfig `yaml:"codex,omitempty"` Gemini GeminiConfig `yaml:"gemini,omitempty"` + Kiro KiroConfig `yaml:"kiro,omitempty"` Interactive bool `yaml:"interactive,omitempty"` // Clipboard enables host clipboard bridging. Default true when nil. Clipboard *bool `yaml:"clipboard,omitempty"` @@ -305,6 +306,16 @@ type GeminiConfig struct { MCP map[string]MCPServerSpec `yaml:"mcp,omitempty"` } +// KiroConfig configures Kiro CLI integration options. +type KiroConfig struct { + // SyncLogs controls whether Kiro session logs are synced to the host. + // Default: false, unless the "kiro" grant is configured (then true). + SyncLogs *bool `yaml:"sync_logs,omitempty"` + + // MCP defines local MCP (Model Context Protocol) server configurations. + MCP map[string]MCPServerSpec `yaml:"mcp,omitempty"` +} + // MarketplaceSpec defines a plugin marketplace source. type MarketplaceSpec struct { // Source is the type of marketplace: "github", "git", or "directory" @@ -443,6 +454,21 @@ func (c *Config) ShouldSyncGeminiLogs() bool { return false } +// ShouldSyncKiroLogs returns true if Kiro session logs should be synced. +// - If kiro.sync_logs is explicitly set, use that value +// - Otherwise, enable sync_logs if "kiro" is in grants +func (c *Config) ShouldSyncKiroLogs() bool { + if c.Kiro.SyncLogs != nil { + return *c.Kiro.SyncLogs + } + for _, grant := range c.Grants { + if grant == "kiro" || strings.HasPrefix(grant, "kiro:") { + return true + } + } + return false +} + // deprecatedRuntime is kept only to detect and reject old configs. type deprecatedRuntime struct { Node string `yaml:"node,omitempty"` @@ -630,6 +656,13 @@ func Load(dir string) (*Config, error) { } } + // Validate Kiro MCP server specs + for name, spec := range cfg.Kiro.MCP { + if err := validateMCPServerSpec("kiro", name, spec); err != nil { + return nil, err + } + } + // Validate that codex.mcp and gemini.mcp don't both define local MCP servers. // Both write to /workspace/.mcp.json, so only one can be used at a time. if len(cfg.Codex.MCP) > 0 && len(cfg.Gemini.MCP) > 0 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 264b468b..db304c9a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -2623,3 +2623,24 @@ func TestNetworkHostConfig(t *testing.T) { }) } } + +func TestKiroConfigParsesAndSyncLogs(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "moat.yaml") + os.WriteFile(path, []byte("agent: kiro\ngrants: [kiro]\nkiro:\n mcp:\n s1:\n command: foo\n"), 0644) + cfg, err := Load(dir) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if _, ok := cfg.Kiro.MCP["s1"]; !ok { + t.Errorf("kiro.mcp.s1 not parsed: %+v", cfg.Kiro) + } + if !cfg.ShouldSyncKiroLogs() { + t.Error("ShouldSyncKiroLogs() = false, want true (kiro grant present)") + } + no := false + cfg.Kiro.SyncLogs = &no + if cfg.ShouldSyncKiroLogs() { + t.Error("ShouldSyncKiroLogs() = true, want false (explicitly disabled)") + } +} From 3702733501e9da4d4179d4177c34dadf74dfa135 Mon Sep 17 00:00:00 2001 From: Nate Hardison Date: Tue, 19 May 2026 17:27:47 +0000 Subject: [PATCH 10/15] feat(kiro): add 'moat kiro' command --- internal/providers/kiro/cli.go | 113 +++++++++++++++++++++++++++- internal/providers/kiro/cli_test.go | 38 ++++++++++ 2 files changed, 148 insertions(+), 3 deletions(-) create mode 100644 internal/providers/kiro/cli_test.go diff --git a/internal/providers/kiro/cli.go b/internal/providers/kiro/cli.go index 9fbb2d5f..b3742ab9 100644 --- a/internal/providers/kiro/cli.go +++ b/internal/providers/kiro/cli.go @@ -1,6 +1,113 @@ package kiro -import "github.com/spf13/cobra" +import ( + "github.com/spf13/cobra" -// RegisterCLI is implemented in Task 6. -func (p *Provider) RegisterCLI(root *cobra.Command) {} + "github.com/majorcontext/moat/internal/cli" + "github.com/majorcontext/moat/internal/config" + "github.com/majorcontext/moat/internal/credential" +) + +var ( + kiroFlags cli.ExecFlags + kiroPromptFlag string + kiroAllowedHosts []string + kiroWtFlag string +) + +// NetworkHosts returns the hosts Kiro needs network access to. +func NetworkHosts() []string { + hosts := []string{"cli.kiro.dev"} + hosts = append(hosts, kiroAPIHosts...) + hosts = append(hosts, kiroPassthroughHosts...) + return hosts +} + +// DefaultDependencies returns the default dependencies for running Kiro CLI. +func DefaultDependencies() []string { + return []string{"kiro-cli", "git"} +} + +// RegisterCLI registers the `moat kiro` command. Called automatically for +// every AgentProvider by cmd/moat/cli/root.go. +func (p *Provider) RegisterCLI(root *cobra.Command) { + kiroCmd := &cobra.Command{ + Use: "kiro [workspace] [flags]", + Short: "Run Kiro CLI in an isolated container", + Long: `Run the Kiro CLI in an isolated container with automatic credential injection. + +Your workspace is mounted at /workspace inside the container. Kiro credentials +are injected transparently via the Moat proxy - Kiro never sees raw tokens. + +Examples: + # Start Kiro in the current directory (interactive) + moat kiro + + # Start Kiro in a specific project + moat kiro ./my-project + + # Ask Kiro to do something specific (non-interactive) + moat kiro -p "explain this codebase" + + # Add additional grants (e.g., for GitHub API access) + moat kiro --grant github + +Use 'moat list' to see running and recent runs.`, + Args: cobra.ArbitraryArgs, + RunE: runKiro, + } + + cli.AddExecFlags(kiroCmd, &kiroFlags) + kiroCmd.Flags().StringVarP(&kiroPromptFlag, "prompt", "p", "", "run with prompt (non-interactive mode)") + kiroCmd.Flags().StringSliceVar(&kiroAllowedHosts, "allow-host", nil, "additional hosts to allow network access to") + kiroCmd.Flags().StringVar(&kiroWtFlag, "worktree", "", "run in a git worktree for this branch") + kiroCmd.Flags().StringVar(&kiroWtFlag, "wt", "", "alias for --worktree") + _ = kiroCmd.Flags().MarkHidden("wt") + + root.AddCommand(kiroCmd) +} + +func runKiro(cmd *cobra.Command, args []string) error { + return cli.RunProvider(cmd, args, cli.ProviderRunConfig{ + Name: "kiro", + Flags: &kiroFlags, + PromptFlag: kiroPromptFlag, + AllowedHosts: kiroAllowedHosts, + WtFlag: kiroWtFlag, + GetCredentialGrant: GetCredentialName, + Dependencies: DefaultDependencies(), + NetworkHosts: NetworkHosts(), + SupportsInitialPrompt: true, + DryRunNote: "Note: No Kiro token configured. Run 'moat grant kiro'.", + BuildCommand: func(promptFlag, initialPrompt string) ([]string, error) { + containerCmd := []string{"kiro-cli", "chat", "--trust-all-tools", "--trust-tools=execute_bash"} + if promptFlag != "" { + return append(containerCmd, "--no-interactive", promptFlag), nil + } + if initialPrompt != "" { + containerCmd = append(containerCmd, initialPrompt) + } + return containerCmd, nil + }, + ConfigureAgent: func(cfg *config.Config) { + syncLogs := true + cfg.Kiro.SyncLogs = &syncLogs + }, + }) +} + +// GetCredentialName returns "kiro" if a Kiro credential exists, else "". +func GetCredentialName() string { + key, err := credential.DefaultEncryptionKey() + if err != nil { + return "" + } + store, err := credential.NewFileStore(credential.DefaultStoreDir(), key) + if err != nil { + return "" + } + if _, err := store.Get(credential.ProviderKiro); err == nil { + return "kiro" + } + return "" +} diff --git a/internal/providers/kiro/cli_test.go b/internal/providers/kiro/cli_test.go new file mode 100644 index 00000000..43641195 --- /dev/null +++ b/internal/providers/kiro/cli_test.go @@ -0,0 +1,38 @@ +package kiro + +import ( + "slices" + "testing" + + "github.com/spf13/cobra" +) + +func TestNetworkHosts(t *testing.T) { + hosts := NetworkHosts() + for _, want := range []string{"q.*.amazonaws.com", "cognito-identity.*.amazonaws.com", "cli.kiro.dev"} { + if !slices.Contains(hosts, want) { + t.Errorf("NetworkHosts() missing %q: %v", want, hosts) + } + } +} + +func TestDefaultDependencies(t *testing.T) { + deps := DefaultDependencies() + if !slices.Contains(deps, "kiro-cli") || !slices.Contains(deps, "git") { + t.Errorf("DefaultDependencies() = %v, want kiro-cli and git", deps) + } +} + +func TestRegisterCLIAddsKiroCommand(t *testing.T) { + root := &cobra.Command{Use: "moat"} + (&Provider{}).RegisterCLI(root) + found := false + for _, c := range root.Commands() { + if c.Name() == "kiro" { + found = true + } + } + if !found { + t.Error("RegisterCLI did not add 'kiro' command") + } +} From 75789d6337f684fb4655f282a20d804d533dc4d9 Mon Sep 17 00:00:00 2001 From: Nate Hardison Date: Tue, 19 May 2026 17:32:13 +0000 Subject: [PATCH 11/15] feat(kiro): register provider --- internal/providers/register.go | 1 + internal/providers/register_test.go | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 internal/providers/register_test.go diff --git a/internal/providers/register.go b/internal/providers/register.go index c348a747..61618d44 100644 --- a/internal/providers/register.go +++ b/internal/providers/register.go @@ -12,6 +12,7 @@ import ( _ "github.com/majorcontext/moat/internal/providers/gemini" // registers Gemini/Google provider _ "github.com/majorcontext/moat/internal/providers/github" // registers GitHub provider _ "github.com/majorcontext/moat/internal/providers/graphite" // registers Graphite provider + _ "github.com/majorcontext/moat/internal/providers/kiro" // registers Kiro provider _ "github.com/majorcontext/moat/internal/providers/meta" // registers Meta provider _ "github.com/majorcontext/moat/internal/providers/npm" // registers npm provider _ "github.com/majorcontext/moat/internal/providers/oauth" // registers OAuth provider diff --git a/internal/providers/register_test.go b/internal/providers/register_test.go new file mode 100644 index 00000000..dfe86635 --- /dev/null +++ b/internal/providers/register_test.go @@ -0,0 +1,16 @@ +package providers + +import ( + "testing" + + "github.com/majorcontext/moat/internal/provider" +) + +func TestKiroProviderRegistered(t *testing.T) { + if provider.Get("kiro") == nil { + t.Fatal("kiro provider not registered") + } + if provider.GetAgent("kiro") == nil { + t.Fatal("kiro provider does not implement AgentProvider") + } +} From 70d59715f9efacac4ead587289a8464f764d219e Mon Sep 17 00:00:00 2001 From: Nate Hardison Date: Tue, 19 May 2026 17:35:18 +0000 Subject: [PATCH 12/15] feat(run): detect kiro init from grant or kiro-cli dependency --- internal/run/imageneeds.go | 10 ++++++++++ internal/run/imageneeds_test.go | 17 +++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/internal/run/imageneeds.go b/internal/run/imageneeds.go index 010e9a17..9df6e721 100644 --- a/internal/run/imageneeds.go +++ b/internal/run/imageneeds.go @@ -84,6 +84,13 @@ func resolveImageNeedsWithStore(grants []string, depList []deps.Dependency, stor initSet["gemini"] = true } } + + case "kiro": + if store != nil { + if _, err := store.Get(credential.ProviderKiro); err == nil { + initSet["kiro"] = true + } + } } // Check InitFileProvider interface using the original grant name @@ -102,6 +109,9 @@ func resolveImageNeedsWithStore(grants []string, depList []deps.Dependency, stor if !initSet["gemini"] && hasDep(depList, "gemini-cli") { initSet["gemini"] = true } + if !initSet["kiro"] && hasDep(depList, "kiro-cli") { + initSet["kiro"] = true + } for name := range initSet { needs.initProviders = append(needs.initProviders, name) diff --git a/internal/run/imageneeds_test.go b/internal/run/imageneeds_test.go index e8652145..76ea43e0 100644 --- a/internal/run/imageneeds_test.go +++ b/internal/run/imageneeds_test.go @@ -145,6 +145,23 @@ func TestResolveImageNeedsGeminiCLIDep(t *testing.T) { } } +func TestResolveImageNeedsKiro(t *testing.T) { + store := newMockStore() + store.Save(credential.Credential{Provider: credential.ProviderKiro, Token: "t", CreatedAt: time.Now()}) + + needs := resolveImageNeedsWithStore([]string{"kiro"}, nil, store) + if !contains(needs.initProviders, "kiro") { + t.Errorf("initProviders = %v, want to contain kiro", needs.initProviders) + } +} + +func TestResolveImageNeedsKiroDepFallback(t *testing.T) { + needs := resolveImageNeedsWithStore(nil, []deps.Dependency{{Name: "kiro-cli"}}, nil) + if !contains(needs.initProviders, "kiro") { + t.Errorf("initProviders = %v, want to contain kiro (dep fallback)", needs.initProviders) + } +} + func TestResolveImageNeedsGrantAndDep(t *testing.T) { // When a grant already covers claude, the dep fallback shouldn't duplicate. depList := []deps.Dependency{{Name: "claude-code"}} From e810d2f4ed598c46e6fbb132d9598b6f66ea39bc Mon Sep 17 00:00:00 2001 From: Nate Hardison Date: Tue, 19 May 2026 17:44:56 +0000 Subject: [PATCH 13/15] feat(run): wire kiro PrepareContainer dispatch (incl. local + remote MCP) - Add needsKiroInit detection via slices.Contains(imgNeeds.initProviders, "kiro") - Import providers/kiro for KiroAPIKeyPlaceholder in grantToPlaceholder - Add kiro cases to grantToEnvVar (KIRO_API_KEY) and grantToPlaceholder - Add full kiro PrepareContainer dispatch block after the gemini block, mirroring the codex/gemini pattern with credential lookup, remote MCP relay URL construction, local MCP server config, and cleanupAgentConfig chaining on all error paths - Add KiroConfigTempDir field to Run struct and assign/cleanup alongside GeminiConfigTempDir throughout the run lifecycle - Add cleanupAgentConfig(kiroConfig) to all downstream error paths that clean up geminiConfig (13 locations inside services/buildkit/container creation/storage/audit blocks) Wildcard finding (Step 4): gatekeeper v0.2.0 credential injection uses exact host-string map keys (proxy.go getCredentials), not the wildcard pattern matching used by the network firewall. The patterns q.*.amazonaws.com and *.q.*.amazonaws.com in kiroAPIHosts would never match real hostnames during credential injection. Changed kiroAPIHosts to the single concrete host "q.us-east-1.amazonaws.com". NetworkHosts() (firewall allowlist) still uses wildcard patterns for passthrough hosts since that path calls matchHost() which does support wildcards. Updated cli_test.go and constants.go accordingly; fixed prealloc lint in cli.go. --- internal/providers/kiro/cli.go | 3 +- internal/providers/kiro/cli_test.go | 2 +- internal/providers/kiro/constants.go | 16 ++-- internal/run/manager.go | 128 ++++++++++++++++++++++++++- internal/run/run.go | 5 ++ 5 files changed, 145 insertions(+), 9 deletions(-) diff --git a/internal/providers/kiro/cli.go b/internal/providers/kiro/cli.go index b3742ab9..57471b54 100644 --- a/internal/providers/kiro/cli.go +++ b/internal/providers/kiro/cli.go @@ -17,7 +17,8 @@ var ( // NetworkHosts returns the hosts Kiro needs network access to. func NetworkHosts() []string { - hosts := []string{"cli.kiro.dev"} + hosts := make([]string, 0, 1+len(kiroAPIHosts)+len(kiroPassthroughHosts)) + hosts = append(hosts, "cli.kiro.dev") hosts = append(hosts, kiroAPIHosts...) hosts = append(hosts, kiroPassthroughHosts...) return hosts diff --git a/internal/providers/kiro/cli_test.go b/internal/providers/kiro/cli_test.go index 43641195..849a2d79 100644 --- a/internal/providers/kiro/cli_test.go +++ b/internal/providers/kiro/cli_test.go @@ -9,7 +9,7 @@ import ( func TestNetworkHosts(t *testing.T) { hosts := NetworkHosts() - for _, want := range []string{"q.*.amazonaws.com", "cognito-identity.*.amazonaws.com", "cli.kiro.dev"} { + for _, want := range []string{"q.us-east-1.amazonaws.com", "cognito-identity.*.amazonaws.com", "cli.kiro.dev"} { if !slices.Contains(hosts, want) { t.Errorf("NetworkHosts() missing %q: %v", want, hosts) } diff --git a/internal/providers/kiro/constants.go b/internal/providers/kiro/constants.go index d67072a6..ce83f539 100644 --- a/internal/providers/kiro/constants.go +++ b/internal/providers/kiro/constants.go @@ -11,13 +11,17 @@ const KiroInitMountPath = "/moat/kiro-init" // kiroAPIHosts are the hosts the proxy injects the Kiro Bearer token for. // -// VERIFICATION POINT (spec §Verification 3): if gatekeeper v0.2.0 does not -// match wildcard host patterns for credential injection, replace this slice -// with the single concrete host "q.us-east-1.amazonaws.com" and document how -// to add other regions. Confirm during implementation (see Task 10 Step "verify"). +// gatekeeper v0.2.0 credential injection uses exact host-string map lookups +// (proxy.go getCredentials), not the wildcard pattern matching used by the +// network firewall. Wildcard patterns in SetCredentialWithGrant are stored +// verbatim as map keys and will never match real hostnames. Only concrete +// hostnames are effective for credential injection. +// +// Kiro CLI calls q.us-east-1.amazonaws.com (us-east-1 is the only region +// currently used in production). To support additional regions in the future, +// add their concrete hostnames here (e.g. "q.eu-west-1.amazonaws.com"). var kiroAPIHosts = []string{ - "q.*.amazonaws.com", - "*.q.*.amazonaws.com", + "q.us-east-1.amazonaws.com", } // kiroPassthroughHosts are allowlisted but receive no credential injection. diff --git a/internal/run/manager.go b/internal/run/manager.go index 946f7411..1bb66d7d 100644 --- a/internal/run/manager.go +++ b/internal/run/manager.go @@ -43,6 +43,7 @@ import ( _ "github.com/majorcontext/moat/internal/providers" // register all credential providers awsprov "github.com/majorcontext/moat/internal/providers/aws" "github.com/majorcontext/moat/internal/providers/claude" // only for settings types (LoadAllSettings, Settings, MarketplaceConfig) - provider setup uses provider interfaces + "github.com/majorcontext/moat/internal/providers/kiro" "github.com/majorcontext/moat/internal/routing" "github.com/majorcontext/moat/internal/runctx" "github.com/majorcontext/moat/internal/secrets" @@ -1719,6 +1720,7 @@ region = %s needsClaudeInit := slices.Contains(imgNeeds.initProviders, "claude") needsCodexInit := slices.Contains(imgNeeds.initProviders, "codex") needsGeminiInit := slices.Contains(imgNeeds.initProviders, "gemini") + needsKiroInit := slices.Contains(imgNeeds.initProviders, "kiro") // Hooks config for image hashing, Dockerfile generation, and pre_run var hooks *deps.HooksConfig @@ -2255,6 +2257,110 @@ region = %s proxyEnv = append(proxyEnv, geminiConfig.Env...) } + // Set up Kiro staging directory for init script. + var kiroConfig *provider.ContainerConfig + hasKiroLocalMCP := opts.Config != nil && len(opts.Config.Kiro.MCP) > 0 + if needsKiroInit || hasKiroLocalMCP || (opts.Config != nil && opts.Config.ShouldSyncKiroLogs()) { + kiroProvider := provider.GetAgent("kiro") + if kiroProvider == nil { + cleanupDaemonRun() + cleanupSSH(sshServer) + cleanupAgentConfig(claudeConfig) + cleanupAgentConfig(codexConfig) + cleanupAgentConfig(geminiConfig) + return nil, fmt.Errorf("kiro provider not registered") + } + + var kiroCred *provider.Credential + if needsKiroInit { + key, keyErr := credential.DefaultEncryptionKey() + if keyErr == nil { + store, storeErr := credential.NewFileStore(credential.DefaultStoreDir(), key) + if storeErr == nil { + if cred, err := store.Get(credential.ProviderKiro); err == nil { + kiroCred = provider.FromLegacy(cred) + } + } + } + } + + kiroMCPServers := make(map[string]provider.MCPServerConfig) + if opts.Config != nil && len(opts.Config.MCP) > 0 { + proxyAddr := fmt.Sprintf("%s:%d", m.defaultRuntime().GetHostAddress(), r.ProxyPort) + for _, mcp := range opts.Config.MCP { + relayURL := fmt.Sprintf("http://%s/mcp/%s/%s", proxyAddr, r.ProxyAuthToken, mcp.Name) + mc := provider.MCPServerConfig{URL: relayURL} + if mcp.Auth != nil { + mc.Headers = map[string]string{mcp.Auth.Header: "moat-stub-" + mcp.Auth.Grant} + } + kiroMCPServers[mcp.Name] = mc + } + } + + var kiroLocalMCP map[string]provider.LocalMCPServerConfig + if opts.Config != nil && len(opts.Config.Kiro.MCP) > 0 { + kiroLocalMCP = make(map[string]provider.LocalMCPServerConfig) + for name, spec := range opts.Config.Kiro.MCP { + env := spec.Env + if spec.Grant != "" { + v, ok := grantToEnvVar(spec.Grant) + if !ok { + cleanupDaemonRun() + cleanupSSH(sshServer) + cleanupAgentConfig(claudeConfig) + cleanupAgentConfig(codexConfig) + cleanupAgentConfig(geminiConfig) + return nil, fmt.Errorf("kiro.mcp.%s: unknown grant %q (supported: github, openai, anthropic, gemini, kiro)", name, spec.Grant) + } + if !hasGrant(opts.Config.Grants, spec.Grant) { + cleanupDaemonRun() + cleanupSSH(sshServer) + cleanupAgentConfig(claudeConfig) + cleanupAgentConfig(codexConfig) + cleanupAgentConfig(geminiConfig) + return nil, fmt.Errorf("kiro.mcp.%s: grant %q not declared in top-level grants list — add 'grants: [%s]' to agent.yaml", name, spec.Grant, spec.Grant) + } + if env == nil { + env = make(map[string]string) + } else { + envCopy := make(map[string]string, len(env)+1) + for k, val := range env { + envCopy[k] = val + } + env = envCopy + } + env[v] = grantToPlaceholder(spec.Grant) + } + kiroLocalMCP[name] = provider.LocalMCPServerConfig{ + Command: spec.Command, + Args: spec.Args, + Env: env, + Cwd: spec.Cwd, + } + } + } + + var prepErr error + kiroConfig, prepErr = kiroProvider.PrepareContainer(ctx, provider.PrepareOpts{ + Credential: kiroCred, + ContainerHome: containerHome, + MCPServers: kiroMCPServers, + RuntimeContext: renderedContext, + LocalMCPServers: kiroLocalMCP, + }) + if prepErr != nil { + cleanupDaemonRun() + cleanupSSH(sshServer) + cleanupAgentConfig(claudeConfig) + cleanupAgentConfig(codexConfig) + cleanupAgentConfig(geminiConfig) + return nil, fmt.Errorf("preparing Kiro container config: %w", prepErr) + } + + mounts = append(mounts, kiroConfig.Mounts...) + proxyEnv = append(proxyEnv, kiroConfig.Env...) + } + // MCP servers are now configured via .claude.json in the staging directory // (handled by the claude provider's PrepareContainer), not via environment variables. @@ -2440,6 +2546,7 @@ region = %s cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) cleanupAgentConfig(geminiConfig) + cleanupAgentConfig(kiroConfig) return nil, err } } @@ -2453,6 +2560,7 @@ region = %s cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) cleanupAgentConfig(geminiConfig) + cleanupAgentConfig(kiroConfig) return nil, fmt.Errorf("service dependencies require network support") } networkName := fmt.Sprintf("moat-%s", r.ID) @@ -2464,6 +2572,7 @@ region = %s cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) cleanupAgentConfig(geminiConfig) + cleanupAgentConfig(kiroConfig) return nil, fmt.Errorf("creating service network: %w", netErr) } r.NetworkID = networkID @@ -2499,6 +2608,7 @@ region = %s cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) cleanupAgentConfig(geminiConfig) + cleanupAgentConfig(kiroConfig) return nil, fmt.Errorf("configuring %s service: %w", dep.Name, err) } @@ -2513,6 +2623,7 @@ region = %s cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) cleanupAgentConfig(geminiConfig) + cleanupAgentConfig(kiroConfig) return nil, fmt.Errorf("creating cache directory for %s: %w", dep.Name, mkdirErr) } } @@ -2525,6 +2636,7 @@ region = %s cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) cleanupAgentConfig(geminiConfig) + cleanupAgentConfig(kiroConfig) return nil, fmt.Errorf("starting %s service: %w", dep.Name, err) } @@ -2543,6 +2655,7 @@ region = %s cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) cleanupAgentConfig(geminiConfig) + cleanupAgentConfig(kiroConfig) return nil, fmt.Errorf("creating run storage: %w", err) } r.Store = store @@ -2565,6 +2678,7 @@ region = %s cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) cleanupAgentConfig(geminiConfig) + cleanupAgentConfig(kiroConfig) return nil, fmt.Errorf("%s: wait: false is incompatible with provisioning — "+ "items cannot be pulled until the service is ready\n\n"+ "Either remove wait: false or remove the provisioned items", @@ -2583,6 +2697,7 @@ region = %s cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) cleanupAgentConfig(geminiConfig) + cleanupAgentConfig(kiroConfig) return nil, fmt.Errorf("%s service failed to become ready: %w\n\n"+ "Check run logs:\n moat logs %s\n\n"+ "Or disable wait:\n services:\n %s:\n wait: false", @@ -2612,6 +2727,7 @@ region = %s cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) cleanupAgentConfig(geminiConfig) + cleanupAgentConfig(kiroConfig) return nil, fmt.Errorf("%s service provisioning failed: %w\n\n"+ "Check run logs:\n moat logs %s", dep.Name, err, r.ID) @@ -2759,6 +2875,7 @@ region = %s cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) cleanupAgentConfig(geminiConfig) + cleanupAgentConfig(kiroConfig) return nil, fmt.Errorf("creating container: %w", err) } @@ -2781,6 +2898,9 @@ region = %s if geminiConfig != nil { r.GeminiConfigTempDir = geminiConfig.StagingDir } + if kiroConfig != nil { + r.KiroConfigTempDir = kiroConfig.StagingDir + } // Ensure proxy is running if we have ports to expose if len(ports) > 0 { @@ -2820,6 +2940,7 @@ region = %s cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) cleanupAgentConfig(geminiConfig) + cleanupAgentConfig(kiroConfig) return nil, fmt.Errorf("creating run storage: %w", storeErr) } r.Store = runStore @@ -2843,6 +2964,7 @@ region = %s cleanupAgentConfig(claudeConfig) cleanupAgentConfig(codexConfig) cleanupAgentConfig(geminiConfig) + cleanupAgentConfig(kiroConfig) return nil, fmt.Errorf("opening audit store: %w", err) } r.AuditStore = auditStore @@ -3570,7 +3692,7 @@ func (m *Manager) cleanupResources(ctx context.Context, r *Run) { } // Clean up temp directories - for _, dir := range []string{r.awsTempDir, r.ClaudeConfigTempDir, r.CodexConfigTempDir, r.GeminiConfigTempDir} { + for _, dir := range []string{r.awsTempDir, r.ClaudeConfigTempDir, r.CodexConfigTempDir, r.GeminiConfigTempDir, r.KiroConfigTempDir} { if dir != "" { if err := os.RemoveAll(dir); err != nil { log.Debug("cleanup: failed to remove temp dir", "path", dir, "error", err) @@ -4407,6 +4529,8 @@ func grantToEnvVar(grant string) (string, bool) { return "ANTHROPIC_API_KEY", true case "gemini": return "GEMINI_API_KEY", true + case "kiro": + return "KIRO_API_KEY", true default: return "", false } @@ -4425,6 +4549,8 @@ func grantToPlaceholder(grant string) string { return credential.GeminiAPIKeyPlaceholder case "github": return credential.GitHubTokenPlaceholder + case "kiro": + return kiro.KiroAPIKeyPlaceholder case "openai": return credential.OpenAIAPIKeyPlaceholder default: diff --git a/internal/run/run.go b/internal/run/run.go index 0922a1f7..ff4a5348 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -118,6 +118,11 @@ type Run struct { // cleaned up when the run is stopped or destroyed. GeminiConfigTempDir string + // KiroConfigTempDir is the temporary directory containing Kiro configuration files + // (settings/cli.json, settings/mcp.json, agents/default.json) that are mounted into + // the container. This should be cleaned up when the run is stopped or destroyed. + KiroConfigTempDir string + // BuildKit sidecar fields (docker:dind only) BuildkitContainerID string NetworkID string From 9f0a42fd98cdbdf4273251569f9b18293d5cfd1d Mon Sep 17 00:00:00 2001 From: Nate Hardison Date: Tue, 19 May 2026 18:15:26 +0000 Subject: [PATCH 14/15] docs: document kiro-cli support --- CHANGELOG.md | 4 + docs/content/guides/14-kiro.md | 190 +++++++++++++++++++++++++ docs/content/reference/01-cli.md | 76 +++++++++- docs/content/reference/02-moat-yaml.md | 74 +++++++++- 4 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 docs/content/guides/14-kiro.md diff --git a/CHANGELOG.md b/CHANGELOG.md index c3c31fd1..24be239d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Moat is pre-1.0. The CLI interface and `moat.yaml` schema may change between min ## Unreleased +### Added + +- **Kiro CLI support** — `moat kiro` runs the Kiro CLI in an isolated container with transparent credential injection; `moat grant kiro` stores a Kiro token. Supports local and remote MCP and runtime-context injection. ([#NNN](https://github.com/majorcontext/moat/pull/NNN)) + ## v0.5.1 — 2026-04-28 Patch release with one security fix (IPv6 egress firewall) and a batch of run-lifecycle and proxy fixes. Adds `MOAT_HOME` for relocating moat state, a multi-runtime manager so Docker and Apple containers can coexist in one install, TUI debug shortcuts, and Python 3.13/3.14 support. Gatekeeper is extracted to its own repository. diff --git a/docs/content/guides/14-kiro.md b/docs/content/guides/14-kiro.md new file mode 100644 index 00000000..99c5117b --- /dev/null +++ b/docs/content/guides/14-kiro.md @@ -0,0 +1,190 @@ +--- +title: "Running Kiro" +navTitle: "Kiro" +description: "Run the Kiro CLI in an isolated container with credential injection." +keywords: ["moat", "kiro", "ai agent", "coding assistant"] +--- + +# Running Kiro + +This guide covers running the Kiro CLI in a Moat container. + +## Prerequisites + +- Moat installed +- A Kiro API token + +## Granting Kiro credentials + +Run `moat grant kiro` to configure authentication: + +```bash +$ moat grant kiro + +Enter your Kiro token: ... + +Kiro token saved to ~/.moat/credentials/kiro.enc +``` + +You can also set `KIRO_API_KEY` in your environment before running the command: + +```bash +export KIRO_API_KEY="..." +moat grant kiro +``` + +Kiro tokens are static credentials — there is no automatic refresh. When a token expires, re-run `moat grant kiro` to replace it. + +### How credentials are injected + +The actual credential is never in the container environment. Moat's proxy intercepts requests to the Kiro API and injects the real token at the network layer. The container receives only a placeholder value for `KIRO_API_KEY`. See [Credential management](../concepts/02-credentials.md) for details. + +## Running Kiro + +### Interactive mode + +Start Kiro in the current directory: + +```bash +moat kiro +``` + +Start in a specific project: + +```bash +moat kiro ./my-project +``` + +Kiro launches in interactive mode with full access to the mounted workspace. + +### Non-interactive mode + +Run with a prompt: + +```bash +moat kiro -p "explain this codebase" +moat kiro -p "fix the failing tests" +moat kiro -p "add input validation to the user registration form" +``` + +Kiro executes the prompt and exits when complete. + +### Named runs + +Give your run a name for reference: + +```bash +moat kiro --name feature-auth ./my-project +``` + +The name appears in `moat list` and makes it easier to manage multiple runs. + +## Adding GitHub access + +Grant GitHub access so Kiro can interact with repositories: + +```bash +moat kiro --grant github ./my-project +``` + +Configure in `moat.yaml` for repeated use: + +```yaml +name: my-kiro-project +agent: kiro + +grants: + - kiro + - github +``` + +Then: + +```bash +moat kiro ./my-project +``` + +## Local MCP servers + +Run MCP servers as processes inside the container using the `kiro.mcp` section in `moat.yaml`: + +```yaml +name: my-kiro-project +agent: kiro + +grants: + - kiro + +kiro: + mcp: + filesystem: + command: npx + args: ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"] +``` + +The MCP server configuration is written to `~/.kiro/settings/mcp.json` inside the container. + +## Remote MCP servers + +Remote HTTP MCP servers are configured with the top-level `mcp:` field and relayed through the Moat proxy: + +```yaml +name: my-kiro-project +agent: kiro + +grants: + - kiro + +mcp: + - name: context7 + url: https://mcp.context7.com/mcp + auth: + grant: mcp-context7 + header: CONTEXT7_API_KEY +``` + +Store the MCP server credential before running: + +```bash +moat grant mcp context7 +``` + +## Network hosts + +Kiro uses the following hosts. Moat allows them automatically when the `kiro` grant is configured: + +| Host | Purpose | +|------|---------| +| `q.us-east-1.amazonaws.com` | Kiro API (credential injection applies to this host) | +| `cognito-identity.*.amazonaws.com` | Cognito identity (allowlisted, no credential injection) | +| `cli.kiro.dev` | Kiro CLI installer | + +**Note on regions:** Credential injection is scoped to `q.us-east-1.amazonaws.com`. This is the only region currently in use. If Kiro adds support for additional regions in the future, their hostnames must be listed explicitly — the credential injection layer does not wildcard-match hostnames. + +## Allowing additional hosts + +To allow access to additional hosts: + +```bash +moat kiro --allow-host example.com ./my-project +``` + +Or configure in `moat.yaml`: + +```yaml +network: + rules: + - example.com + - "*.internal.corp" +``` + +## Workspace snapshots + +Moat captures workspace snapshots for recovery and rollback. See [Snapshots](./07-snapshots.md) for configuration and usage. + +## Related guides + +- [SSH access](./04-ssh.md) — Set up SSH for git operations +- [Snapshots](./07-snapshots.md) — Protect your workspace with snapshots +- [MCP servers](./09-mcp.md) — Configure remote and local MCP servers +- [Exposing ports](./06-ports.md) — Access services running inside containers diff --git a/docs/content/reference/01-cli.md b/docs/content/reference/01-cli.md index da083519..d43cf45e 100644 --- a/docs/content/reference/01-cli.md +++ b/docs/content/reference/01-cli.md @@ -37,7 +37,7 @@ If a name matches multiple runs, batch commands (`stop`, `destroy`) prompt for c ## Common agent flags -The agent commands (`moat claude`, `moat codex`, `moat gemini`) share the following flags. These flags work identically across `moat claude`, `moat codex`, and `moat gemini`. +The agent commands (`moat claude`, `moat codex`, `moat gemini`, `moat kiro`) share the following flags. These flags work identically across `moat claude`, `moat codex`, `moat gemini`, and `moat kiro`. | Flag | Description | |------|-------------| @@ -388,6 +388,60 @@ moat gemini --rebuild --- +## moat kiro + +Run the Kiro CLI in a container. + +``` +moat kiro [workspace] [flags] [-- initial-prompt] +``` + +In addition to the command-specific flags below, `moat kiro` accepts all [common agent flags](#common-agent-flags). + +### Arguments + +| Argument | Description | +|----------|-------------| +| `workspace` | Workspace directory (default: current directory) | +| `initial-prompt` | Text after `--` is passed to Kiro as an initial prompt (interactive mode) | + +### Command-specific flags + +| Flag | Description | +|------|-------------| +| `-p`, `--prompt TEXT` | Run non-interactive with prompt | + +### Examples + +```bash +# Interactive Kiro CLI +moat kiro + +# In specific directory +moat kiro ./my-project + +# Interactive with initial prompt (Kiro stays open) +moat kiro -- "explain this codebase" + +# Non-interactive with prompt (exits when done) +moat kiro -p "explain this codebase" +moat kiro -p "fix the bug in main.py" + +# With GitHub access +moat kiro --grant github + +# Named run +moat kiro --name my-feature + +# Run in a git worktree (non-interactive with prompt) +moat kiro --worktree=dark-mode --prompt "implement dark mode" + +# Force rebuild +moat kiro --rebuild +``` + +--- + ## moat wt Create or reuse a git worktree for a branch and start a run in it. @@ -497,6 +551,7 @@ moat grant [:] | `anthropic` | Anthropic API key | | `openai` | OpenAI (API key) | | `gemini` | Google Gemini (Gemini CLI OAuth or API key) | +| `kiro` | Kiro API token | | `npm` | npm registries (.npmrc, `NPM_TOKEN`, or manual) | | `aws` | AWS (IAM role assumption) | | `oauth` | OAuth 2.0 (authorization code flow with PKCE) | @@ -555,6 +610,21 @@ If no Gemini CLI credentials are found, falls directly to the API key prompt. moat grant gemini ``` +### moat grant kiro + +Stores a Kiro API token. Reads from the `KIRO_API_KEY` environment variable, or prompts interactively. + +Kiro tokens are static credentials with no automatic refresh. When a token expires, re-run `moat grant kiro` to replace it. + +```bash +# Enter token interactively +moat grant kiro + +# Read from environment variable +export KIRO_API_KEY="..." +moat grant kiro +``` + ### moat grant npm Grant npm registry credentials. Auto-discovers registries from `~/.npmrc` and `NPM_TOKEN` environment variable. @@ -784,6 +854,9 @@ moat revoke moat revoke github moat revoke claude # revokes OAuth token moat revoke anthropic # revokes API key +moat revoke openai +moat revoke gemini +moat revoke kiro moat revoke npm moat revoke ssh:github.com @@ -1452,6 +1525,7 @@ This command scans for and removes temporary directories matching these patterns - `agentops-aws-*` - AWS credential helper directories (legacy) - `moat-claude-staging-*` - Claude configuration staging directories - `moat-codex-staging-*` - Codex configuration staging directories +- `moat-kiro-staging-*` - Kiro configuration staging directories - `moat-npm-*` - npm credential configuration directories Only directories older than `--min-age` are removed. diff --git a/docs/content/reference/02-moat-yaml.md b/docs/content/reference/02-moat-yaml.md index 31490e22..4e230d13 100644 --- a/docs/content/reference/02-moat-yaml.md +++ b/docs/content/reference/02-moat-yaml.md @@ -134,6 +134,18 @@ gemini: grant: github cwd: /workspace +# Kiro CLI +kiro: + sync_logs: true + mcp: + my_server: + command: /path/to/server + args: ["--flag"] + env: + VAR: value + grant: github + cwd: /workspace + # Language servers language_servers: - go @@ -396,6 +408,7 @@ grants: | `anthropic` | Anthropic API | | `openai` | OpenAI API | | `gemini` | Google Gemini API | +| `kiro` | Kiro API | | `npm` | npm registries | | `ssh:HOSTNAME` | SSH access to specific host | | `oauth:NAME` | OAuth credentials for a service | @@ -1278,7 +1291,7 @@ mcp: MCP servers running on the host machine (e.g., `http://localhost:3000`) are not accessible from inside the container. Moat's proxy relay bridges this gap -- the relay runs on the host and forwards container requests to the host-local server. -**Note:** For sandbox-local MCP servers running inside the container, use `claude.mcp`, `codex.mcp`, or `gemini.mcp` instead. +**Note:** For sandbox-local MCP servers running inside the container, use `claude.mcp`, `codex.mcp`, `gemini.mcp`, or `kiro.mcp` instead. **See also:** [MCP servers guide](../guides/09-mcp.md#remote-mcp-servers) @@ -1443,6 +1456,65 @@ When `grant` is specified, the corresponding environment variable is set automat --- +## Kiro + +### kiro.sync_logs + +Mount Kiro's session logs directory for observability. + +```yaml +kiro: + sync_logs: true +``` + +- Type: `boolean` +- Default: `true` (when `kiro` grant is used) + +When enabled, Kiro session logs are synced to the host at `~/.moat/runs//kiro/`. + +### kiro.mcp + +Sandbox-local MCP servers that run as child processes inside the container. Configuration is written to `~/.kiro/settings/mcp.json` inside the container. + +```yaml +kiro: + mcp: + filesystem: + command: npx + args: ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"] + env: + VAR: value + grant: github + cwd: /workspace +``` + +- Type: `map[string]object` +- Default: `{}` + +#### MCP server fields + +| Field | Type | Description | +|-------|------|-------------| +| `command` | `string` | Server executable path (required) | +| `args` | `array[string]` | Command arguments | +| `env` | `map[string]string` | Environment variables | +| `grant` | `string` | Credential to inject as an environment variable | +| `cwd` | `string` | Working directory for the server process | + +When `grant` is specified, the corresponding environment variable is set automatically: + +| Grant | Environment variable | +|-------|---------------------| +| `github` | `GITHUB_TOKEN` | +| `openai` | `OPENAI_API_KEY` | +| `anthropic` | `ANTHROPIC_API_KEY` | +| `gemini` | `GEMINI_API_KEY` | +| `kiro` | `KIRO_API_KEY` | + +**Note:** For remote HTTP-based MCP servers, use the top-level `mcp:` field instead. See [MCP servers guide](../guides/09-mcp.md#remote-mcp-servers). + +--- + ## Snapshots ### snapshots.disabled From 57047a9b4670ba1f5f5acbab149614a5e1ef088d Mon Sep 17 00:00:00 2001 From: Nate Hardison Date: Tue, 19 May 2026 18:24:28 +0000 Subject: [PATCH 15/15] fix(kiro): copy staged ~/.kiro config into container via moat-init The kiro agent.go set MOAT_KIRO_INIT and mounted the staging dir, but moat-init.sh had no block to copy it into ~/.kiro at container startup, so cli.json/mcp.json/agents/steering never reached the container. Add a MOAT_KIRO_INIT block mirroring the gemini block, copying the settings/, agents/, and steering/ subdirectories. Caught in final holistic review; the implementation plan omitted this step. --- internal/deps/scripts/moat-init.sh | 31 ++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/internal/deps/scripts/moat-init.sh b/internal/deps/scripts/moat-init.sh index 90342b98..f4f0baf8 100644 --- a/internal/deps/scripts/moat-init.sh +++ b/internal/deps/scripts/moat-init.sh @@ -259,6 +259,37 @@ if [ -n "$MOAT_GEMINI_INIT" ] && [ -d "$MOAT_GEMINI_INIT" ]; then fi fi +# Kiro CLI Setup +# When MOAT_KIRO_INIT is set to the staging directory path, copy the staged +# ~/.kiro tree (settings/cli.json, settings/mcp.json, agents/default.json, +# steering/moat-context.md) to its final location. +if [ -n "$MOAT_KIRO_INIT" ] && [ -d "$MOAT_KIRO_INIT" ]; then + # Determine target home directory + if [ "$(id -u)" = "0" ] && id moatuser >/dev/null 2>&1; then + TARGET_HOME="/home/moatuser" + else + TARGET_HOME="$HOME" + fi + + # Create ~/.kiro directory + mkdir -p "$TARGET_HOME/.kiro" + + # Copy each staged subdirectory's contents (including dotfiles), preserving + # permissions. The kiro provider always stages settings/, agents/, and + # steering/ (steering/ may be empty when no runtime context is set). + for sub in settings agents steering; do + if [ -d "$MOAT_KIRO_INIT/$sub" ]; then + mkdir -p "$TARGET_HOME/.kiro/$sub" + cp -rp "$MOAT_KIRO_INIT/$sub/." "$TARGET_HOME/.kiro/$sub/" + fi + done + + # Ensure moatuser owns all the files if we're running as root + if [ "$(id -u)" = "0" ] && id moatuser >/dev/null 2>&1; then + chown -R moatuser:moatuser "$TARGET_HOME/.kiro" 2>/dev/null || true + fi +fi + # Provider Init Files # When MOAT_INIT_FILES is set, it contains tab-delimited records (one per line): #