From c9f4958854d9c48de56f8a0e0ac1b597057236f2 Mon Sep 17 00:00:00 2001 From: Alex Guglielmone Nemi Date: Tue, 7 Jul 2026 01:29:06 +0100 Subject: [PATCH] feat(adapter/kiro): first-class Kiro CLI skill runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring Kiro CLI (the rebranded Amazon Q Developer CLI — #320) to parity with Claude Code and OpenCode as a skill runtime, following the docs/adapters/add-a-harness.md recipe (#741). Discovery: Kiro reads .kiro/skills/ (not .agents/skills/), so add a `kiro` row to the skills/setup/agents.md registry plus the per-skill relays; the registry-driven adopt/verify/worktree-init logic then wires it automatically. .agents/.github/.kiro all carry the 69 magpie-* relays. Enforcement (agent-harness score: Kiro now 5/5 substrate tools): - agent-guard: a --kiro preToolUse adapter (kiro_main) over the shared dispatch() core — Kiro feeds {tool_name, tool_input.command, cwd} on stdin and blocks on exit 2 with the reason on stderr. - sandbox-lint --kiro and permission-audit audit-kiro: encode Kiro's allowlist/denylist model (allowedCommands/deniedCommands = \A..\z regex, deny-before-allow, denyByDefault) and flag configs that auto-approve dangerous shell / exfil surfaces. - agent-isolation kiro-iso: the env -i credential-strip launcher (the harness name `kiro` normalises to the `kiro-cli` binary). - spec-loop: a headless `kiro-cli chat --no-interactive` runner profile in lib.sh. Verified: full prek --all-files; per-tool pytest + ruff + mypy; the spec-loop runner fixture (kiro argv); symlink-lint (relays cycle-free + target-correct); discovery — a fresh kiro-cli discovers all skills with the relays present and 0 without (negative control); agent-guard end-to-end — a real .kiro preToolUse hook blocks a Co-Authored-By commit and allows a clean one; vendor-neutrality-score regenerates with Kiro at 5/5. Known limitations: agent-isolation's bubblewrap OS-sandbox layer is unit-tested for the launcher but its e2e is deferred to a bwrap-capable host (as OpenCode's is); a general /magpie-setup auto-installer for non-Claude-only harness enforcement is a follow-up (Claude-only today), so the Kiro hook uses ${MAGPIE_AGENT_GUARD:-.claude/hooks/agent-guard.py}. Generated-by: Kiro CLI (Opus 4.8) --- .gitignore | 2 + .kiro/skills/magpie-audit-finding-fix | 1 + .kiro/skills/magpie-ci-runner-audit | 1 + .kiro/skills/magpie-committer-onboarding | 1 + .../skills/magpie-contributor-activity-sweep | 1 + .kiro/skills/magpie-contributor-nomination | 1 + .kiro/skills/magpie-contributor-sentiment | 1 + .kiro/skills/magpie-contributor-to-committer | 1 + .kiro/skills/magpie-dependency-audit | 1 + .kiro/skills/magpie-flaky-test-triage | 1 + .kiro/skills/magpie-good-first-issue-author | 1 + .kiro/skills/magpie-good-first-issue-sweep | 1 + .kiro/skills/magpie-issue-backlog-stats | 1 + .kiro/skills/magpie-issue-deduplicate | 1 + .kiro/skills/magpie-issue-fix-workflow | 1 + .kiro/skills/magpie-issue-reassess | 1 + .kiro/skills/magpie-issue-reassess-stats | 1 + .kiro/skills/magpie-issue-reproducer | 1 + .kiro/skills/magpie-issue-stale-sweep | 1 + .kiro/skills/magpie-issue-triage | 1 + .kiro/skills/magpie-license-compliance-audit | 1 + .kiro/skills/magpie-list-skills | 1 + .kiro/skills/magpie-mentoring-welcome | 1 + .kiro/skills/magpie-newcomer-issue-explainer | 1 + .kiro/skills/magpie-onboarding-concierge | 1 + .kiro/skills/magpie-optimize-skill | 1 + .../skills/magpie-pairing-multi-agent-review | 1 + .kiro/skills/magpie-pairing-self-review | 1 + .kiro/skills/magpie-pr-management-code-review | 1 + .kiro/skills/magpie-pr-management-mentor | 1 + .kiro/skills/magpie-pr-management-quick-merge | 1 + .kiro/skills/magpie-pr-management-stats | 1 + .kiro/skills/magpie-pr-management-triage | 1 + .kiro/skills/magpie-pr-stale-sweep | 1 + .kiro/skills/magpie-pre-first-pr-check | 1 + .kiro/skills/magpie-release-announce-draft | 1 + .kiro/skills/magpie-release-archive-sweep | 1 + .kiro/skills/magpie-release-audit-report | 1 + .kiro/skills/magpie-release-keys-sync | 1 + .kiro/skills/magpie-release-prepare | 1 + .kiro/skills/magpie-release-promote | 1 + .kiro/skills/magpie-release-rc-cut | 1 + .kiro/skills/magpie-release-verify-rc | 1 + .kiro/skills/magpie-release-vote-draft | 1 + .kiro/skills/magpie-release-vote-tally | 1 + .kiro/skills/magpie-reviewer-routing | 1 + .kiro/skills/magpie-security-cve-allocate | 1 + .../skills/magpie-security-issue-deduplicate | 1 + .kiro/skills/magpie-security-issue-fix | 1 + .kiro/skills/magpie-security-issue-import | 1 + .../magpie-security-issue-import-from-md | 1 + .../magpie-security-issue-import-from-pr | 1 + .../magpie-security-issue-import-from-scan | 1 + ...magpie-security-issue-import-via-forwarder | 1 + .kiro/skills/magpie-security-issue-invalidate | 1 + .kiro/skills/magpie-security-issue-sync | 1 + .kiro/skills/magpie-security-issue-triage | 1 + .../magpie-security-tracker-stats-dashboard | 1 + .kiro/skills/magpie-setup | 1 + .../skills/magpie-setup-isolated-setup-doctor | 1 + .../magpie-setup-isolated-setup-install | 1 + .../skills/magpie-setup-isolated-setup-update | 1 + .../skills/magpie-setup-isolated-setup-verify | 1 + .kiro/skills/magpie-setup-override-upstream | 1 + .kiro/skills/magpie-setup-shared-config-sync | 1 + .kiro/skills/magpie-setup-status | 1 + .kiro/skills/magpie-setup-upstream-fix | 1 + .kiro/skills/magpie-skill-reconciler | 1 + .kiro/skills/magpie-workflow-security-audit | 1 + .kiro/skills/magpie-write-skill | 1 + docs/vendor-neutrality.md | 11 +- skills/setup/agents.md | 1 + tools/agent-guard/README.md | 46 ++++- tools/agent-guard/src/agent_guard/__init__.py | 39 +++- tools/agent-guard/tests/test_kiro.py | 107 +++++++++++ tools/agent-isolation/README.md | 21 ++- tools/agent-isolation/agent-iso.sh | 6 + tools/agent-isolation/tests/test_kiro_iso.py | 153 ++++++++++++++++ tools/permission-audit/README.md | 25 ++- .../src/permission_audit/cli.py | 24 +++ .../src/permission_audit/kiro.py | 170 ++++++++++++++++++ tools/permission-audit/tests/test_kiro.py | 114 ++++++++++++ tools/sandbox-lint/README.md | 21 ++- .../sandbox-lint/src/sandbox_lint/__init__.py | 28 +++ tools/sandbox-lint/src/sandbox_lint/kiro.py | 148 +++++++++++++++ tools/sandbox-lint/tests/test_kiro.py | 120 +++++++++++++ tools/spec-loop/README.md | 2 +- tools/spec-loop/lib.sh | 6 + tools/spec-loop/loop.sh | 1 + tools/spec-loop/specs/spec-loop-runner.md | 5 +- tools/spec-loop/tests/test_runner_fixtures.sh | 8 + 91 files changed, 1106 insertions(+), 21 deletions(-) create mode 120000 .kiro/skills/magpie-audit-finding-fix create mode 120000 .kiro/skills/magpie-ci-runner-audit create mode 120000 .kiro/skills/magpie-committer-onboarding create mode 120000 .kiro/skills/magpie-contributor-activity-sweep create mode 120000 .kiro/skills/magpie-contributor-nomination create mode 120000 .kiro/skills/magpie-contributor-sentiment create mode 120000 .kiro/skills/magpie-contributor-to-committer create mode 120000 .kiro/skills/magpie-dependency-audit create mode 120000 .kiro/skills/magpie-flaky-test-triage create mode 120000 .kiro/skills/magpie-good-first-issue-author create mode 120000 .kiro/skills/magpie-good-first-issue-sweep create mode 120000 .kiro/skills/magpie-issue-backlog-stats create mode 120000 .kiro/skills/magpie-issue-deduplicate create mode 120000 .kiro/skills/magpie-issue-fix-workflow create mode 120000 .kiro/skills/magpie-issue-reassess create mode 120000 .kiro/skills/magpie-issue-reassess-stats create mode 120000 .kiro/skills/magpie-issue-reproducer create mode 120000 .kiro/skills/magpie-issue-stale-sweep create mode 120000 .kiro/skills/magpie-issue-triage create mode 120000 .kiro/skills/magpie-license-compliance-audit create mode 120000 .kiro/skills/magpie-list-skills create mode 120000 .kiro/skills/magpie-mentoring-welcome create mode 120000 .kiro/skills/magpie-newcomer-issue-explainer create mode 120000 .kiro/skills/magpie-onboarding-concierge create mode 120000 .kiro/skills/magpie-optimize-skill create mode 120000 .kiro/skills/magpie-pairing-multi-agent-review create mode 120000 .kiro/skills/magpie-pairing-self-review create mode 120000 .kiro/skills/magpie-pr-management-code-review create mode 120000 .kiro/skills/magpie-pr-management-mentor create mode 120000 .kiro/skills/magpie-pr-management-quick-merge create mode 120000 .kiro/skills/magpie-pr-management-stats create mode 120000 .kiro/skills/magpie-pr-management-triage create mode 120000 .kiro/skills/magpie-pr-stale-sweep create mode 120000 .kiro/skills/magpie-pre-first-pr-check create mode 120000 .kiro/skills/magpie-release-announce-draft create mode 120000 .kiro/skills/magpie-release-archive-sweep create mode 120000 .kiro/skills/magpie-release-audit-report create mode 120000 .kiro/skills/magpie-release-keys-sync create mode 120000 .kiro/skills/magpie-release-prepare create mode 120000 .kiro/skills/magpie-release-promote create mode 120000 .kiro/skills/magpie-release-rc-cut create mode 120000 .kiro/skills/magpie-release-verify-rc create mode 120000 .kiro/skills/magpie-release-vote-draft create mode 120000 .kiro/skills/magpie-release-vote-tally create mode 120000 .kiro/skills/magpie-reviewer-routing create mode 120000 .kiro/skills/magpie-security-cve-allocate create mode 120000 .kiro/skills/magpie-security-issue-deduplicate create mode 120000 .kiro/skills/magpie-security-issue-fix create mode 120000 .kiro/skills/magpie-security-issue-import create mode 120000 .kiro/skills/magpie-security-issue-import-from-md create mode 120000 .kiro/skills/magpie-security-issue-import-from-pr create mode 120000 .kiro/skills/magpie-security-issue-import-from-scan create mode 120000 .kiro/skills/magpie-security-issue-import-via-forwarder create mode 120000 .kiro/skills/magpie-security-issue-invalidate create mode 120000 .kiro/skills/magpie-security-issue-sync create mode 120000 .kiro/skills/magpie-security-issue-triage create mode 120000 .kiro/skills/magpie-security-tracker-stats-dashboard create mode 120000 .kiro/skills/magpie-setup create mode 120000 .kiro/skills/magpie-setup-isolated-setup-doctor create mode 120000 .kiro/skills/magpie-setup-isolated-setup-install create mode 120000 .kiro/skills/magpie-setup-isolated-setup-update create mode 120000 .kiro/skills/magpie-setup-isolated-setup-verify create mode 120000 .kiro/skills/magpie-setup-override-upstream create mode 120000 .kiro/skills/magpie-setup-shared-config-sync create mode 120000 .kiro/skills/magpie-setup-status create mode 120000 .kiro/skills/magpie-setup-upstream-fix create mode 120000 .kiro/skills/magpie-skill-reconciler create mode 120000 .kiro/skills/magpie-workflow-security-audit create mode 120000 .kiro/skills/magpie-write-skill create mode 100644 tools/agent-guard/tests/test_kiro.py create mode 100644 tools/agent-isolation/tests/test_kiro_iso.py create mode 100644 tools/permission-audit/src/permission_audit/kiro.py create mode 100644 tools/permission-audit/tests/test_kiro.py create mode 100644 tools/sandbox-lint/src/sandbox_lint/kiro.py create mode 100644 tools/sandbox-lint/tests/test_kiro.py diff --git a/.gitignore b/.gitignore index 1f2e8ae24..d0d597ac6 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,8 @@ logs !/.agents/skills/magpie-* .claude/skills/* !/.claude/skills/magpie-* +.kiro/skills/* +!/.kiro/skills/magpie-* .github/skills/* !/.github/skills/magpie-* diff --git a/.kiro/skills/magpie-audit-finding-fix b/.kiro/skills/magpie-audit-finding-fix new file mode 120000 index 000000000..aadc23dc9 --- /dev/null +++ b/.kiro/skills/magpie-audit-finding-fix @@ -0,0 +1 @@ +../../.agents/skills/magpie-audit-finding-fix \ No newline at end of file diff --git a/.kiro/skills/magpie-ci-runner-audit b/.kiro/skills/magpie-ci-runner-audit new file mode 120000 index 000000000..da506b8ed --- /dev/null +++ b/.kiro/skills/magpie-ci-runner-audit @@ -0,0 +1 @@ +../../.agents/skills/magpie-ci-runner-audit \ No newline at end of file diff --git a/.kiro/skills/magpie-committer-onboarding b/.kiro/skills/magpie-committer-onboarding new file mode 120000 index 000000000..062b79bc2 --- /dev/null +++ b/.kiro/skills/magpie-committer-onboarding @@ -0,0 +1 @@ +../../.agents/skills/magpie-committer-onboarding \ No newline at end of file diff --git a/.kiro/skills/magpie-contributor-activity-sweep b/.kiro/skills/magpie-contributor-activity-sweep new file mode 120000 index 000000000..20f8f7a7a --- /dev/null +++ b/.kiro/skills/magpie-contributor-activity-sweep @@ -0,0 +1 @@ +../../.agents/skills/magpie-contributor-activity-sweep \ No newline at end of file diff --git a/.kiro/skills/magpie-contributor-nomination b/.kiro/skills/magpie-contributor-nomination new file mode 120000 index 000000000..33297c559 --- /dev/null +++ b/.kiro/skills/magpie-contributor-nomination @@ -0,0 +1 @@ +../../.agents/skills/magpie-contributor-nomination \ No newline at end of file diff --git a/.kiro/skills/magpie-contributor-sentiment b/.kiro/skills/magpie-contributor-sentiment new file mode 120000 index 000000000..c2083842d --- /dev/null +++ b/.kiro/skills/magpie-contributor-sentiment @@ -0,0 +1 @@ +../../.agents/skills/magpie-contributor-sentiment \ No newline at end of file diff --git a/.kiro/skills/magpie-contributor-to-committer b/.kiro/skills/magpie-contributor-to-committer new file mode 120000 index 000000000..74a0c9a39 --- /dev/null +++ b/.kiro/skills/magpie-contributor-to-committer @@ -0,0 +1 @@ +../../.agents/skills/magpie-contributor-to-committer \ No newline at end of file diff --git a/.kiro/skills/magpie-dependency-audit b/.kiro/skills/magpie-dependency-audit new file mode 120000 index 000000000..22e94f896 --- /dev/null +++ b/.kiro/skills/magpie-dependency-audit @@ -0,0 +1 @@ +../../.agents/skills/magpie-dependency-audit \ No newline at end of file diff --git a/.kiro/skills/magpie-flaky-test-triage b/.kiro/skills/magpie-flaky-test-triage new file mode 120000 index 000000000..7fe30ec6a --- /dev/null +++ b/.kiro/skills/magpie-flaky-test-triage @@ -0,0 +1 @@ +../../.agents/skills/magpie-flaky-test-triage \ No newline at end of file diff --git a/.kiro/skills/magpie-good-first-issue-author b/.kiro/skills/magpie-good-first-issue-author new file mode 120000 index 000000000..bdbc0eb3a --- /dev/null +++ b/.kiro/skills/magpie-good-first-issue-author @@ -0,0 +1 @@ +../../.agents/skills/magpie-good-first-issue-author \ No newline at end of file diff --git a/.kiro/skills/magpie-good-first-issue-sweep b/.kiro/skills/magpie-good-first-issue-sweep new file mode 120000 index 000000000..916fb3e79 --- /dev/null +++ b/.kiro/skills/magpie-good-first-issue-sweep @@ -0,0 +1 @@ +../../.agents/skills/magpie-good-first-issue-sweep \ No newline at end of file diff --git a/.kiro/skills/magpie-issue-backlog-stats b/.kiro/skills/magpie-issue-backlog-stats new file mode 120000 index 000000000..304b94c65 --- /dev/null +++ b/.kiro/skills/magpie-issue-backlog-stats @@ -0,0 +1 @@ +../../.agents/skills/magpie-issue-backlog-stats \ No newline at end of file diff --git a/.kiro/skills/magpie-issue-deduplicate b/.kiro/skills/magpie-issue-deduplicate new file mode 120000 index 000000000..534240ba4 --- /dev/null +++ b/.kiro/skills/magpie-issue-deduplicate @@ -0,0 +1 @@ +../../.agents/skills/magpie-issue-deduplicate \ No newline at end of file diff --git a/.kiro/skills/magpie-issue-fix-workflow b/.kiro/skills/magpie-issue-fix-workflow new file mode 120000 index 000000000..8668b7d2f --- /dev/null +++ b/.kiro/skills/magpie-issue-fix-workflow @@ -0,0 +1 @@ +../../.agents/skills/magpie-issue-fix-workflow \ No newline at end of file diff --git a/.kiro/skills/magpie-issue-reassess b/.kiro/skills/magpie-issue-reassess new file mode 120000 index 000000000..d0b1057c7 --- /dev/null +++ b/.kiro/skills/magpie-issue-reassess @@ -0,0 +1 @@ +../../.agents/skills/magpie-issue-reassess \ No newline at end of file diff --git a/.kiro/skills/magpie-issue-reassess-stats b/.kiro/skills/magpie-issue-reassess-stats new file mode 120000 index 000000000..be4558b07 --- /dev/null +++ b/.kiro/skills/magpie-issue-reassess-stats @@ -0,0 +1 @@ +../../.agents/skills/magpie-issue-reassess-stats \ No newline at end of file diff --git a/.kiro/skills/magpie-issue-reproducer b/.kiro/skills/magpie-issue-reproducer new file mode 120000 index 000000000..1f8dbd247 --- /dev/null +++ b/.kiro/skills/magpie-issue-reproducer @@ -0,0 +1 @@ +../../.agents/skills/magpie-issue-reproducer \ No newline at end of file diff --git a/.kiro/skills/magpie-issue-stale-sweep b/.kiro/skills/magpie-issue-stale-sweep new file mode 120000 index 000000000..18b02038c --- /dev/null +++ b/.kiro/skills/magpie-issue-stale-sweep @@ -0,0 +1 @@ +../../.agents/skills/magpie-issue-stale-sweep \ No newline at end of file diff --git a/.kiro/skills/magpie-issue-triage b/.kiro/skills/magpie-issue-triage new file mode 120000 index 000000000..98fa1b1f3 --- /dev/null +++ b/.kiro/skills/magpie-issue-triage @@ -0,0 +1 @@ +../../.agents/skills/magpie-issue-triage \ No newline at end of file diff --git a/.kiro/skills/magpie-license-compliance-audit b/.kiro/skills/magpie-license-compliance-audit new file mode 120000 index 000000000..dbd4526a7 --- /dev/null +++ b/.kiro/skills/magpie-license-compliance-audit @@ -0,0 +1 @@ +../../.agents/skills/magpie-license-compliance-audit \ No newline at end of file diff --git a/.kiro/skills/magpie-list-skills b/.kiro/skills/magpie-list-skills new file mode 120000 index 000000000..86c5d5c8b --- /dev/null +++ b/.kiro/skills/magpie-list-skills @@ -0,0 +1 @@ +../../.agents/skills/magpie-list-skills \ No newline at end of file diff --git a/.kiro/skills/magpie-mentoring-welcome b/.kiro/skills/magpie-mentoring-welcome new file mode 120000 index 000000000..22681849b --- /dev/null +++ b/.kiro/skills/magpie-mentoring-welcome @@ -0,0 +1 @@ +../../.agents/skills/magpie-mentoring-welcome \ No newline at end of file diff --git a/.kiro/skills/magpie-newcomer-issue-explainer b/.kiro/skills/magpie-newcomer-issue-explainer new file mode 120000 index 000000000..eb7da31fa --- /dev/null +++ b/.kiro/skills/magpie-newcomer-issue-explainer @@ -0,0 +1 @@ +../../.agents/skills/magpie-newcomer-issue-explainer \ No newline at end of file diff --git a/.kiro/skills/magpie-onboarding-concierge b/.kiro/skills/magpie-onboarding-concierge new file mode 120000 index 000000000..b89337477 --- /dev/null +++ b/.kiro/skills/magpie-onboarding-concierge @@ -0,0 +1 @@ +../../.agents/skills/magpie-onboarding-concierge \ No newline at end of file diff --git a/.kiro/skills/magpie-optimize-skill b/.kiro/skills/magpie-optimize-skill new file mode 120000 index 000000000..38928cca2 --- /dev/null +++ b/.kiro/skills/magpie-optimize-skill @@ -0,0 +1 @@ +../../.agents/skills/magpie-optimize-skill \ No newline at end of file diff --git a/.kiro/skills/magpie-pairing-multi-agent-review b/.kiro/skills/magpie-pairing-multi-agent-review new file mode 120000 index 000000000..aefce05da --- /dev/null +++ b/.kiro/skills/magpie-pairing-multi-agent-review @@ -0,0 +1 @@ +../../.agents/skills/magpie-pairing-multi-agent-review \ No newline at end of file diff --git a/.kiro/skills/magpie-pairing-self-review b/.kiro/skills/magpie-pairing-self-review new file mode 120000 index 000000000..db045e21c --- /dev/null +++ b/.kiro/skills/magpie-pairing-self-review @@ -0,0 +1 @@ +../../.agents/skills/magpie-pairing-self-review \ No newline at end of file diff --git a/.kiro/skills/magpie-pr-management-code-review b/.kiro/skills/magpie-pr-management-code-review new file mode 120000 index 000000000..f75f26f60 --- /dev/null +++ b/.kiro/skills/magpie-pr-management-code-review @@ -0,0 +1 @@ +../../.agents/skills/magpie-pr-management-code-review \ No newline at end of file diff --git a/.kiro/skills/magpie-pr-management-mentor b/.kiro/skills/magpie-pr-management-mentor new file mode 120000 index 000000000..5c6e19f3f --- /dev/null +++ b/.kiro/skills/magpie-pr-management-mentor @@ -0,0 +1 @@ +../../.agents/skills/magpie-pr-management-mentor \ No newline at end of file diff --git a/.kiro/skills/magpie-pr-management-quick-merge b/.kiro/skills/magpie-pr-management-quick-merge new file mode 120000 index 000000000..04b43f10e --- /dev/null +++ b/.kiro/skills/magpie-pr-management-quick-merge @@ -0,0 +1 @@ +../../.agents/skills/magpie-pr-management-quick-merge \ No newline at end of file diff --git a/.kiro/skills/magpie-pr-management-stats b/.kiro/skills/magpie-pr-management-stats new file mode 120000 index 000000000..b247b1cf9 --- /dev/null +++ b/.kiro/skills/magpie-pr-management-stats @@ -0,0 +1 @@ +../../.agents/skills/magpie-pr-management-stats \ No newline at end of file diff --git a/.kiro/skills/magpie-pr-management-triage b/.kiro/skills/magpie-pr-management-triage new file mode 120000 index 000000000..9ac1831e1 --- /dev/null +++ b/.kiro/skills/magpie-pr-management-triage @@ -0,0 +1 @@ +../../.agents/skills/magpie-pr-management-triage \ No newline at end of file diff --git a/.kiro/skills/magpie-pr-stale-sweep b/.kiro/skills/magpie-pr-stale-sweep new file mode 120000 index 000000000..0a8f6fa63 --- /dev/null +++ b/.kiro/skills/magpie-pr-stale-sweep @@ -0,0 +1 @@ +../../.agents/skills/magpie-pr-stale-sweep \ No newline at end of file diff --git a/.kiro/skills/magpie-pre-first-pr-check b/.kiro/skills/magpie-pre-first-pr-check new file mode 120000 index 000000000..e4d6eacc0 --- /dev/null +++ b/.kiro/skills/magpie-pre-first-pr-check @@ -0,0 +1 @@ +../../.agents/skills/magpie-pre-first-pr-check \ No newline at end of file diff --git a/.kiro/skills/magpie-release-announce-draft b/.kiro/skills/magpie-release-announce-draft new file mode 120000 index 000000000..2aece0be9 --- /dev/null +++ b/.kiro/skills/magpie-release-announce-draft @@ -0,0 +1 @@ +../../.agents/skills/magpie-release-announce-draft \ No newline at end of file diff --git a/.kiro/skills/magpie-release-archive-sweep b/.kiro/skills/magpie-release-archive-sweep new file mode 120000 index 000000000..8237f5362 --- /dev/null +++ b/.kiro/skills/magpie-release-archive-sweep @@ -0,0 +1 @@ +../../.agents/skills/magpie-release-archive-sweep \ No newline at end of file diff --git a/.kiro/skills/magpie-release-audit-report b/.kiro/skills/magpie-release-audit-report new file mode 120000 index 000000000..59ea4e5e9 --- /dev/null +++ b/.kiro/skills/magpie-release-audit-report @@ -0,0 +1 @@ +../../.agents/skills/magpie-release-audit-report \ No newline at end of file diff --git a/.kiro/skills/magpie-release-keys-sync b/.kiro/skills/magpie-release-keys-sync new file mode 120000 index 000000000..e4a62ba5d --- /dev/null +++ b/.kiro/skills/magpie-release-keys-sync @@ -0,0 +1 @@ +../../.agents/skills/magpie-release-keys-sync \ No newline at end of file diff --git a/.kiro/skills/magpie-release-prepare b/.kiro/skills/magpie-release-prepare new file mode 120000 index 000000000..e10457f76 --- /dev/null +++ b/.kiro/skills/magpie-release-prepare @@ -0,0 +1 @@ +../../.agents/skills/magpie-release-prepare \ No newline at end of file diff --git a/.kiro/skills/magpie-release-promote b/.kiro/skills/magpie-release-promote new file mode 120000 index 000000000..fcd25c30f --- /dev/null +++ b/.kiro/skills/magpie-release-promote @@ -0,0 +1 @@ +../../.agents/skills/magpie-release-promote \ No newline at end of file diff --git a/.kiro/skills/magpie-release-rc-cut b/.kiro/skills/magpie-release-rc-cut new file mode 120000 index 000000000..605e18614 --- /dev/null +++ b/.kiro/skills/magpie-release-rc-cut @@ -0,0 +1 @@ +../../.agents/skills/magpie-release-rc-cut \ No newline at end of file diff --git a/.kiro/skills/magpie-release-verify-rc b/.kiro/skills/magpie-release-verify-rc new file mode 120000 index 000000000..50bb2497f --- /dev/null +++ b/.kiro/skills/magpie-release-verify-rc @@ -0,0 +1 @@ +../../.agents/skills/magpie-release-verify-rc \ No newline at end of file diff --git a/.kiro/skills/magpie-release-vote-draft b/.kiro/skills/magpie-release-vote-draft new file mode 120000 index 000000000..78c697132 --- /dev/null +++ b/.kiro/skills/magpie-release-vote-draft @@ -0,0 +1 @@ +../../.agents/skills/magpie-release-vote-draft \ No newline at end of file diff --git a/.kiro/skills/magpie-release-vote-tally b/.kiro/skills/magpie-release-vote-tally new file mode 120000 index 000000000..2aff4878b --- /dev/null +++ b/.kiro/skills/magpie-release-vote-tally @@ -0,0 +1 @@ +../../.agents/skills/magpie-release-vote-tally \ No newline at end of file diff --git a/.kiro/skills/magpie-reviewer-routing b/.kiro/skills/magpie-reviewer-routing new file mode 120000 index 000000000..89dfff49f --- /dev/null +++ b/.kiro/skills/magpie-reviewer-routing @@ -0,0 +1 @@ +../../.agents/skills/magpie-reviewer-routing \ No newline at end of file diff --git a/.kiro/skills/magpie-security-cve-allocate b/.kiro/skills/magpie-security-cve-allocate new file mode 120000 index 000000000..921523b95 --- /dev/null +++ b/.kiro/skills/magpie-security-cve-allocate @@ -0,0 +1 @@ +../../.agents/skills/magpie-security-cve-allocate \ No newline at end of file diff --git a/.kiro/skills/magpie-security-issue-deduplicate b/.kiro/skills/magpie-security-issue-deduplicate new file mode 120000 index 000000000..61f19ec33 --- /dev/null +++ b/.kiro/skills/magpie-security-issue-deduplicate @@ -0,0 +1 @@ +../../.agents/skills/magpie-security-issue-deduplicate \ No newline at end of file diff --git a/.kiro/skills/magpie-security-issue-fix b/.kiro/skills/magpie-security-issue-fix new file mode 120000 index 000000000..993c5cc1c --- /dev/null +++ b/.kiro/skills/magpie-security-issue-fix @@ -0,0 +1 @@ +../../.agents/skills/magpie-security-issue-fix \ No newline at end of file diff --git a/.kiro/skills/magpie-security-issue-import b/.kiro/skills/magpie-security-issue-import new file mode 120000 index 000000000..5a6b3b5e2 --- /dev/null +++ b/.kiro/skills/magpie-security-issue-import @@ -0,0 +1 @@ +../../.agents/skills/magpie-security-issue-import \ No newline at end of file diff --git a/.kiro/skills/magpie-security-issue-import-from-md b/.kiro/skills/magpie-security-issue-import-from-md new file mode 120000 index 000000000..64df4d753 --- /dev/null +++ b/.kiro/skills/magpie-security-issue-import-from-md @@ -0,0 +1 @@ +../../.agents/skills/magpie-security-issue-import-from-md \ No newline at end of file diff --git a/.kiro/skills/magpie-security-issue-import-from-pr b/.kiro/skills/magpie-security-issue-import-from-pr new file mode 120000 index 000000000..7f0ad3642 --- /dev/null +++ b/.kiro/skills/magpie-security-issue-import-from-pr @@ -0,0 +1 @@ +../../.agents/skills/magpie-security-issue-import-from-pr \ No newline at end of file diff --git a/.kiro/skills/magpie-security-issue-import-from-scan b/.kiro/skills/magpie-security-issue-import-from-scan new file mode 120000 index 000000000..aa6fd9e77 --- /dev/null +++ b/.kiro/skills/magpie-security-issue-import-from-scan @@ -0,0 +1 @@ +../../.agents/skills/magpie-security-issue-import-from-scan \ No newline at end of file diff --git a/.kiro/skills/magpie-security-issue-import-via-forwarder b/.kiro/skills/magpie-security-issue-import-via-forwarder new file mode 120000 index 000000000..0f29ded6f --- /dev/null +++ b/.kiro/skills/magpie-security-issue-import-via-forwarder @@ -0,0 +1 @@ +../../.agents/skills/magpie-security-issue-import-via-forwarder \ No newline at end of file diff --git a/.kiro/skills/magpie-security-issue-invalidate b/.kiro/skills/magpie-security-issue-invalidate new file mode 120000 index 000000000..0bd6690f6 --- /dev/null +++ b/.kiro/skills/magpie-security-issue-invalidate @@ -0,0 +1 @@ +../../.agents/skills/magpie-security-issue-invalidate \ No newline at end of file diff --git a/.kiro/skills/magpie-security-issue-sync b/.kiro/skills/magpie-security-issue-sync new file mode 120000 index 000000000..6408ae086 --- /dev/null +++ b/.kiro/skills/magpie-security-issue-sync @@ -0,0 +1 @@ +../../.agents/skills/magpie-security-issue-sync \ No newline at end of file diff --git a/.kiro/skills/magpie-security-issue-triage b/.kiro/skills/magpie-security-issue-triage new file mode 120000 index 000000000..ae7d4bbbf --- /dev/null +++ b/.kiro/skills/magpie-security-issue-triage @@ -0,0 +1 @@ +../../.agents/skills/magpie-security-issue-triage \ No newline at end of file diff --git a/.kiro/skills/magpie-security-tracker-stats-dashboard b/.kiro/skills/magpie-security-tracker-stats-dashboard new file mode 120000 index 000000000..57883a5b1 --- /dev/null +++ b/.kiro/skills/magpie-security-tracker-stats-dashboard @@ -0,0 +1 @@ +../../.agents/skills/magpie-security-tracker-stats-dashboard \ No newline at end of file diff --git a/.kiro/skills/magpie-setup b/.kiro/skills/magpie-setup new file mode 120000 index 000000000..625c6aa3d --- /dev/null +++ b/.kiro/skills/magpie-setup @@ -0,0 +1 @@ +../../.agents/skills/magpie-setup \ No newline at end of file diff --git a/.kiro/skills/magpie-setup-isolated-setup-doctor b/.kiro/skills/magpie-setup-isolated-setup-doctor new file mode 120000 index 000000000..ab0d0fb0b --- /dev/null +++ b/.kiro/skills/magpie-setup-isolated-setup-doctor @@ -0,0 +1 @@ +../../.agents/skills/magpie-setup-isolated-setup-doctor \ No newline at end of file diff --git a/.kiro/skills/magpie-setup-isolated-setup-install b/.kiro/skills/magpie-setup-isolated-setup-install new file mode 120000 index 000000000..0de941447 --- /dev/null +++ b/.kiro/skills/magpie-setup-isolated-setup-install @@ -0,0 +1 @@ +../../.agents/skills/magpie-setup-isolated-setup-install \ No newline at end of file diff --git a/.kiro/skills/magpie-setup-isolated-setup-update b/.kiro/skills/magpie-setup-isolated-setup-update new file mode 120000 index 000000000..f9907e1a7 --- /dev/null +++ b/.kiro/skills/magpie-setup-isolated-setup-update @@ -0,0 +1 @@ +../../.agents/skills/magpie-setup-isolated-setup-update \ No newline at end of file diff --git a/.kiro/skills/magpie-setup-isolated-setup-verify b/.kiro/skills/magpie-setup-isolated-setup-verify new file mode 120000 index 000000000..929dcc243 --- /dev/null +++ b/.kiro/skills/magpie-setup-isolated-setup-verify @@ -0,0 +1 @@ +../../.agents/skills/magpie-setup-isolated-setup-verify \ No newline at end of file diff --git a/.kiro/skills/magpie-setup-override-upstream b/.kiro/skills/magpie-setup-override-upstream new file mode 120000 index 000000000..27fbec7a4 --- /dev/null +++ b/.kiro/skills/magpie-setup-override-upstream @@ -0,0 +1 @@ +../../.agents/skills/magpie-setup-override-upstream \ No newline at end of file diff --git a/.kiro/skills/magpie-setup-shared-config-sync b/.kiro/skills/magpie-setup-shared-config-sync new file mode 120000 index 000000000..9c9224e7a --- /dev/null +++ b/.kiro/skills/magpie-setup-shared-config-sync @@ -0,0 +1 @@ +../../.agents/skills/magpie-setup-shared-config-sync \ No newline at end of file diff --git a/.kiro/skills/magpie-setup-status b/.kiro/skills/magpie-setup-status new file mode 120000 index 000000000..4eaf51a22 --- /dev/null +++ b/.kiro/skills/magpie-setup-status @@ -0,0 +1 @@ +../../.agents/skills/magpie-setup-status \ No newline at end of file diff --git a/.kiro/skills/magpie-setup-upstream-fix b/.kiro/skills/magpie-setup-upstream-fix new file mode 120000 index 000000000..6779e068b --- /dev/null +++ b/.kiro/skills/magpie-setup-upstream-fix @@ -0,0 +1 @@ +../../.agents/skills/magpie-setup-upstream-fix \ No newline at end of file diff --git a/.kiro/skills/magpie-skill-reconciler b/.kiro/skills/magpie-skill-reconciler new file mode 120000 index 000000000..64156835f --- /dev/null +++ b/.kiro/skills/magpie-skill-reconciler @@ -0,0 +1 @@ +../../.agents/skills/magpie-skill-reconciler \ No newline at end of file diff --git a/.kiro/skills/magpie-workflow-security-audit b/.kiro/skills/magpie-workflow-security-audit new file mode 120000 index 000000000..bb5f28d4b --- /dev/null +++ b/.kiro/skills/magpie-workflow-security-audit @@ -0,0 +1 @@ +../../.agents/skills/magpie-workflow-security-audit \ No newline at end of file diff --git a/.kiro/skills/magpie-write-skill b/.kiro/skills/magpie-write-skill new file mode 120000 index 000000000..23274dfc2 --- /dev/null +++ b/.kiro/skills/magpie-write-skill @@ -0,0 +1 @@ +../../.agents/skills/magpie-write-skill \ No newline at end of file diff --git a/docs/vendor-neutrality.md b/docs/vendor-neutrality.md index d09da8eb9..de64ae7ad 100644 --- a/docs/vendor-neutrality.md +++ b/docs/vendor-neutrality.md @@ -583,24 +583,24 @@ Organization scope (declared, orthogonal to vendor): ASF = 14, agnostic = 55. | Substrate tool | Substrate | Harness support | Verdict | |---|---|---|---| -| `agent-guard` | action-guard | Claude Code, OpenCode | ✅ portable | -| `agent-isolation` | sandbox | Claude Code, OpenCode | ✅ portable | +| `agent-guard` | action-guard | Claude Code, Kiro, OpenCode | ✅ portable | +| `agent-isolation` | sandbox | Claude Code, Kiro, OpenCode | ✅ portable | | `dashboard-generator` | analytics | any | ✅ agnostic | | `dev` | framework-dev | any | ✅ agnostic | | `egress-gateway` | sandbox | any | ✅ agnostic | -| `permission-audit` | sandbox | Claude Code, OpenCode | ✅ portable | +| `permission-audit` | sandbox | Claude Code, Kiro, OpenCode | ✅ portable | | `pilot-report-validator` | framework-dev | any | ✅ agnostic | | `pr-management-stats` | analytics | any | ✅ agnostic | | `preflight-audit` | analytics | any | ✅ agnostic | | `privacy-llm` | privacy | any | ✅ agnostic | | `probe-templates` | sandbox | any | ✅ agnostic | -| `sandbox-lint` | sandbox | Claude Code, OpenCode | ✅ portable | +| `sandbox-lint` | sandbox | Claude Code, Kiro, OpenCode | ✅ portable | | `security-tracker-stats-dashboard` | analytics | any | ✅ agnostic | | `skill-and-tool-validator` | framework-dev | any | ✅ agnostic | | `skill-evals` | framework-dev | any | ✅ agnostic | | `skill-reconciler-diff` | framework-dev | any | ✅ agnostic | | `spec-inventory` | framework-dev, analytics | any | ✅ agnostic | -| `spec-loop` | framework-dev | Claude Code, Codex, Cursor, Gemini CLI, OpenCode | ✅ portable | +| `spec-loop` | framework-dev | Claude Code, Codex, Cursor, Gemini CLI, Kiro, OpenCode | ✅ portable | | `spec-status-index` | framework-dev, analytics | any | ✅ agnostic | | `spec-validator` | framework-dev | any | ✅ agnostic | | `symlink-lint` | framework-dev | any | ✅ agnostic | @@ -612,6 +612,7 @@ Harness → substrate tools it supports: - **Codex** (1): `spec-loop` - **Cursor** (1): `spec-loop` - **Gemini CLI** (1): `spec-loop` +- **Kiro** (5): `agent-guard`, `agent-isolation`, `permission-audit`, `sandbox-lint`, `spec-loop` - **OpenCode** (5): `agent-guard`, `agent-isolation`, `permission-audit`, `sandbox-lint`, `spec-loop` - **any harness** (17): `dashboard-generator`, `dev`, `egress-gateway`, `pilot-report-validator`, `pr-management-stats`, `preflight-audit`, `privacy-llm`, `probe-templates`, `security-tracker-stats-dashboard`, `skill-and-tool-validator`, `skill-evals`, `skill-reconciler-diff`, `spec-inventory`, `spec-status-index`, `spec-validator`, `symlink-lint`, `vendor-neutrality-score` diff --git a/skills/setup/agents.md b/skills/setup/agents.md index f8210e6d6..bc870ddfd 100644 --- a/skills/setup/agents.md +++ b/skills/setup/agents.md @@ -27,6 +27,7 @@ go"*, never as *"what files contain"*. | `github` | `.github/skills/` | native (relay) | GitHub's skill loader | | `windsurf` | `.windsurf/skills/` | native (relay) | Windsurf | | `goose` | `.goose/skills/` | native (relay) | Goose | +| `kiro` | `.kiro/skills/` | native (relay) | Kiro CLI | The table is **extensible**: a new agent that wants framework skills is one new row (`id`, project dir, kind), nothing else — diff --git a/tools/agent-guard/README.md b/tools/agent-guard/README.md index 21aff8113..5619e96aa 100644 --- a/tools/agent-guard/README.md +++ b/tools/agent-guard/README.md @@ -11,6 +11,7 @@ - [Per-command overrides](#per-command-overrides) - [Wiring](#wiring) - [OpenCode](#opencode) + - [Kiro CLI](#kiro-cli) - [Contributing guards](#contributing-guards) - [Tests](#tests) @@ -23,7 +24,7 @@ **Capability:** substrate:action-guard -**Harness:** Claude Code, OpenCode +**Harness:** Claude Code, OpenCode, Kiro A deterministic pre-execution guard dispatcher. It inspects every shell command **before it runs** and **denies** the ones that would break a hard framework @@ -39,6 +40,9 @@ so every wired harness enforces an identical rule set from one source of truth: - **OpenCode** — a [plugin](https://opencode.ai/docs/plugins/) on the `tool.execute.before` hook for the `bash` tool, which blocks a call by throwing (`agent-guard.py --opencode`). See [Wiring](#wiring). +- **Kiro CLI** — a [`preToolUse`](https://kiro.dev/docs/cli/hooks) hook on the + `execute_bash` matcher, which blocks a call when the hook exits `2` + (`agent-guard.py --kiro`, reason on stderr). See [Wiring](#wiring). It is **stdlib-only** and is invoked directly as `python3 /agent_guard/__init__.py` (never via `uv run`) so it returns in a @@ -141,6 +145,46 @@ to point elsewhere. Because both harnesses call `dispatch()`, the bundled and skill-contributed guards, the `MAGPIE_*` overrides, and the deny reasons are byte-for-byte identical across the two; nothing about a guard is harness-aware. +### Kiro CLI + +The same engine backs [Kiro CLI](https://kiro.dev/docs/cli/hooks) via its +`preToolUse` hook. Kiro pipes the hook event +(`{"tool_name": "execute_bash", "tool_input": {"command": …}, "cwd": …}`) to +the hook command on stdin and **blocks** the tool call when that command exits +`2`, returning its stderr to the model — so `agent-guard.py --kiro` matches the +shell tool, runs the shared core, and on a deny writes the reason to stderr and +exits `2`, the Kiro equivalent of a Claude `PreToolUse` deny. + +Register it in the agent configuration's `hooks` field +([reference](https://kiro.dev/docs/cli/custom-agents/configuration-reference#hooks-field)): + +```json +{ + "hooks": { + "preToolUse": [ + { + "matcher": "execute_bash", + "command": "python3 \"${MAGPIE_AGENT_GUARD:-.claude/hooks/agent-guard.py}\" --kiro" + } + ] + } +} +``` + +The engine is one shared, harness-agnostic file (`--kiro` only selects the I/O +adapter). On a repo already wired for Claude Code it lives at +`.claude/hooks/agent-guard.py`, so the hook reuses it; a **Kiro-only** adopter +(no `.claude/`) points `MAGPIE_AGENT_GUARD` at wherever the engine is installed. +Note: `/magpie-setup` currently installs the engine only on the Claude path — a +general installer that drops it for non-Claude-only adopters is a pending +follow-up (see [`docs/adapters/add-a-harness.md`](../../docs/adapters/add-a-harness.md)). + +Because every harness calls `dispatch()`, the bundled and skill-contributed +guards, the `MAGPIE_*` overrides, and the deny reasons are identical to the +Claude Code and OpenCode paths — verified end-to-end: with this hook wired, +Kiro refuses a `Co-Authored-By` commit (quoting the `commit-trailer` reason and +leaving the commit uncreated) while a clean commit proceeds. + ## Contributing guards The hook is **wired once**. Beyond the two bundled guards, additional guards are diff --git a/tools/agent-guard/src/agent_guard/__init__.py b/tools/agent-guard/src/agent_guard/__init__.py index 68aa7a199..6305299c9 100644 --- a/tools/agent-guard/src/agent_guard/__init__.py +++ b/tools/agent-guard/src/agent_guard/__init__.py @@ -552,17 +552,54 @@ def opencode_main() -> int: return ALLOW_EXIT +def kiro_main() -> int: + """Harness-neutral entry point for Kiro CLI's ``preToolUse`` hook. + + Reads Kiro's hook event on stdin — ``{"tool_name": "...", "tool_input": + {"command": "..."}, "cwd": "..."}`` — matches the shell tool + (``execute_bash`` / its ``shell`` alias / ``execute_cmd``), and runs the + **same** + :func:`dispatch` core as every other harness. On a deny it writes the + reason to **stderr** (which Kiro returns to the model) and exits + ``DENY_EXIT`` (2 = block for ``PreToolUse``); on allow, a non-shell tool, + or any malformed input it exits ``ALLOW_EXIT`` — fail-open, exactly like + the Claude and OpenCode paths. Only this thin I/O shell differs; the guard + decisions are identical across harnesses. + """ + try: + event = json.load(sys.stdin) + except (json.JSONDecodeError, ValueError): + return ALLOW_EXIT + if not isinstance(event, dict): + return ALLOW_EXIT + if event.get("tool_name") not in ("execute_bash", "shell", "execute_cmd"): + return ALLOW_EXIT + tool_input = event.get("tool_input") + if not isinstance(tool_input, dict): + return ALLOW_EXIT + command = str(tool_input.get("command", "")) + cwd = event.get("cwd") + reason = dispatch(command, cwd if isinstance(cwd, str) else None) + if reason: + sys.stderr.write(reason + "\n") + return DENY_EXIT + return ALLOW_EXIT + + def cli(argv: list[str] | None = None) -> int: """Route to the harness adapter named on the command line. No argument → the Claude Code ``PreToolUse`` hook (:func:`main`); the - ``--opencode`` flag → the OpenCode adapter (:func:`opencode_main`). A single + ``--opencode`` flag → the OpenCode adapter (:func:`opencode_main`); the + ``--kiro`` flag → the Kiro CLI adapter (:func:`kiro_main`). A single self-contained file thus serves every wired harness, so ``/magpie-setup`` ships one script and each harness points its own hook at it. """ args = sys.argv[1:] if argv is None else argv if args and args[0] == "--opencode": return opencode_main() + if args and args[0] == "--kiro": + return kiro_main() return main() diff --git a/tools/agent-guard/tests/test_kiro.py b/tools/agent-guard/tests/test_kiro.py new file mode 100644 index 000000000..38ccea7a7 --- /dev/null +++ b/tools/agent-guard/tests/test_kiro.py @@ -0,0 +1,107 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for the Kiro CLI adapter entry point. + +Kiro's ``preToolUse`` hook feeds ``{"tool_name", "tool_input": {"command"}, +"cwd"}`` on stdin and blocks a tool call on exit code 2 with the reason on +STDERR. These tests drive the ``--kiro`` I/O shell and assert it reaches the +same guard decisions as the shared :func:`agent_guard.dispatch` core that +backs the Claude Code hook and the OpenCode adapter. +""" + +from __future__ import annotations + +import io +import json + +import pytest + +import agent_guard +from agent_guard import ALLOW_EXIT, DENY_EXIT, kiro_main + + +def _feed(monkeypatch: pytest.MonkeyPatch, payload: object) -> None: + text = payload if isinstance(payload, str) else json.dumps(payload) + monkeypatch.setattr("sys.stdin", io.StringIO(text)) + + +def _event(command: str, tool_name: str = "execute_bash") -> dict: + return { + "hook_event_name": "preToolUse", + "cwd": ".", + "tool_name": tool_name, + "tool_input": {"command": command}, + } + + +def test_denied_command_exits_deny_with_reason_on_stderr( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + # A Co-Authored-By trailer is one of the bundled deny rules. + _feed(monkeypatch, _event("git commit -m 'x\n\nCo-Authored-By: A '")) + rc = kiro_main() + captured = capsys.readouterr() + assert rc == DENY_EXIT + # Kiro surfaces STDERR (not STDOUT) to the model. + assert "commit-trailer" in captured.err + assert captured.out == "" + + +def test_allowed_command_exits_allow_silently( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _feed(monkeypatch, _event("git status")) + rc = kiro_main() + captured = capsys.readouterr() + assert rc == ALLOW_EXIT + assert captured.out == "" and captured.err == "" + + +def test_shell_alias_is_matched(monkeypatch: pytest.MonkeyPatch) -> None: + _feed(monkeypatch, _event("git commit -m 'x\n\nCo-Authored-By: A '", tool_name="shell")) + assert kiro_main() == DENY_EXIT + + +def test_execute_cmd_alias_is_matched(monkeypatch: pytest.MonkeyPatch) -> None: + _feed(monkeypatch, _event("git commit -m 'x\n\nCo-Authored-By: A '", tool_name="execute_cmd")) + assert kiro_main() == DENY_EXIT + + +def test_non_shell_tool_is_fast_path_allow(monkeypatch: pytest.MonkeyPatch) -> None: + _feed(monkeypatch, {"tool_name": "fs_read", "tool_input": {"path": "/x"}}) + assert kiro_main() == ALLOW_EXIT + + +def test_malformed_stdin_fails_open(monkeypatch: pytest.MonkeyPatch) -> None: + _feed(monkeypatch, "not json {{{") + assert kiro_main() == ALLOW_EXIT + + +def test_missing_tool_input_allows(monkeypatch: pytest.MonkeyPatch) -> None: + _feed(monkeypatch, {"tool_name": "execute_bash"}) + assert kiro_main() == ALLOW_EXIT + + +def test_verdict_matches_opencode_core(monkeypatch: pytest.MonkeyPatch) -> None: + """Differential: --kiro and --opencode reach the same dispatch() verdict.""" + cmd = "git commit -m 'x\n\nCo-Authored-By: A '" + _feed(monkeypatch, _event(cmd)) + kiro_rc = kiro_main() + _feed(monkeypatch, {"command": cmd}) + opencode_rc = agent_guard.opencode_main() + assert kiro_rc == opencode_rc == DENY_EXIT diff --git a/tools/agent-isolation/README.md b/tools/agent-isolation/README.md index 09e8cbc24..4af599f65 100644 --- a/tools/agent-isolation/README.md +++ b/tools/agent-isolation/README.md @@ -20,7 +20,7 @@ **Capability:** substrate:sandbox -**Harness:** Claude Code, OpenCode +**Harness:** Claude Code, OpenCode, Kiro This directory ships the moving pieces the framework's [`docs/setup/secure-agent-setup.md`](../../docs/setup/secure-agent-setup.md) document @@ -30,14 +30,17 @@ plain shell scripts plus a TOML manifest of pinned upstream versions. The clean-environment launcher [`agent-iso.sh`](agent-iso.sh) is -agent-agnostic at its core: it exposes a `claude-iso` entry point (the -default) and an `opencode-iso` entry point that launch **Claude Code** and -**OpenCode** respectively under the same `env -i` credential strip. Only the -Claude path adds the in-process `--settings` sandbox grant; OpenCode takes its -filesystem isolation from the OS-level sandbox of the secure setup. Isolate -OpenCode by sourcing the script and calling `opencode-iso`, or when running it -directly, `AGENT_ISO_AGENT=opencode bash agent-iso.sh …` (or a symlink named -`opencode-iso`). +agent-agnostic at its core: it exposes `claude-iso` (the default), +`opencode-iso`, and `kiro-iso` entry points that launch **Claude Code**, +**OpenCode**, and **Kiro CLI** respectively under the same `env -i` +credential strip. Only the Claude path adds the in-process `--settings` +sandbox grant; OpenCode and Kiro take their filesystem isolation from the +OS-level sandbox of the secure setup. Isolate OpenCode or Kiro by sourcing +the script and calling `opencode-iso` / `kiro-iso`, or when running it +directly, `AGENT_ISO_AGENT=opencode bash agent-iso.sh …` / +`AGENT_ISO_AGENT=kiro bash agent-iso.sh …` (or a symlink named +`opencode-iso` / `kiro-iso`). The Kiro harness name `kiro` normalises to +its `kiro-cli` binary. ## Prerequisites diff --git a/tools/agent-isolation/agent-iso.sh b/tools/agent-isolation/agent-iso.sh index cf3416157..860044b5d 100755 --- a/tools/agent-isolation/agent-iso.sh +++ b/tools/agent-isolation/agent-iso.sh @@ -80,6 +80,10 @@ # `claude-iso` / `opencode-iso` entry points are thin wrappers over this. agent_iso_run() { local agent="$1" + # The Kiro harness is invoked as `kiro` but ships the `kiro-cli` binary; + # normalise so both `AGENT_ISO_AGENT=kiro` and the `kiro-iso` entry point + # resolve the real executable. (Claude/OpenCode names match their binaries.) + [[ "$agent" == "kiro" ]] && agent="kiro-cli" shift # Resolve the agent binary on PATH before clobbering the env so @@ -276,9 +280,11 @@ claude_iso_main() { agent_iso_run "${AGENT_ISO_AGENT:-claude}" "$@"; } if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then claude-iso() { agent_iso_run claude "$@"; } opencode-iso() { agent_iso_run opencode "$@"; } + kiro-iso() { agent_iso_run kiro "$@"; } else case "$(basename "${0}")" in opencode-iso*) agent_iso_run opencode "$@" ;; + kiro-iso*) agent_iso_run kiro "$@" ;; *) agent_iso_run "${AGENT_ISO_AGENT:-claude}" "$@" ;; esac fi diff --git a/tools/agent-isolation/tests/test_kiro_iso.py b/tools/agent-isolation/tests/test_kiro_iso.py new file mode 100644 index 000000000..292bb9605 --- /dev/null +++ b/tools/agent-isolation/tests/test_kiro_iso.py @@ -0,0 +1,153 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for the Kiro entry point of agent-iso.sh (`kiro-iso`). + +Same strategy as ``test_opencode_iso.py``, with one Kiro-specific wrinkle: +the harness is invoked as ``kiro`` but ships the ``kiro-cli`` binary, so the +wrapper normalises the name. The fake binary is therefore ``kiro-cli``, and +the banner/error use ``kiro-cli-iso``. Verifies the clean-env strip and that +the Claude-only ``--settings`` injection is not applied. + +(The bubblewrap OS sandbox layer is not exercised here — it is unavailable in +this environment; this test covers the ``env -i`` credential-strip launcher, +which is the harness-portable part.) +""" + +from __future__ import annotations + +import os +import shutil +import stat +import subprocess +from pathlib import Path + +SCRIPT = Path(__file__).parent.parent / "agent-iso.sh" +BASH = shutil.which("bash") or "/bin/bash" + + +def _make_fake_kiro(tmp_path: Path) -> Path: + """A fake 'kiro-cli' that prints ARG: lines for its argv, then its env.""" + fake = tmp_path / "kiro-cli" + fake.write_text('#!/bin/sh\nfor a in "$@"; do echo "ARG:$a"; done\nprintenv\n') + fake.chmod(fake.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + return tmp_path + + +def _run_kiro( + tmp_path: Path, + extra_env: dict | None = None, + extra_args: list | None = None, + via_symlink: bool = False, +) -> subprocess.CompletedProcess: + bin_dir = _make_fake_kiro(tmp_path) + env: dict[str, str] = { + "PATH": f"{bin_dir}:{os.environ.get('PATH', '/usr/bin:/bin')}", + "HOME": "/tmp/testhome", + "USER": "testuser", + "SHELL": "/bin/sh", + "TERM": "xterm", + "LANG": "en_US.UTF-8", + } + if not via_symlink: + # The harness name `kiro` must normalise to the `kiro-cli` binary. + env["AGENT_ISO_AGENT"] = "kiro" + if extra_env: + env.update(extra_env) + + script: Path = SCRIPT + if via_symlink: + link = tmp_path / "kiro-iso.sh" + if not link.exists(): + link.symlink_to(SCRIPT) + script = link + + return subprocess.run( + [BASH, str(script)] + (extra_args or []), + env=env, + cwd=str(tmp_path), + capture_output=True, + text=True, + ) + + +def _parse_env(stdout: str) -> dict[str, str]: + result: dict[str, str] = {} + for line in stdout.splitlines(): + if line.startswith("ARG:"): + continue + if "=" in line: + key, _, val = line.partition("=") + result[key] = val + return result + + +def _argv(stdout: str) -> list[str]: + return [ + line[len("ARG:") :] for line in stdout.splitlines() if line.startswith("ARG:") + ] + + +class TestKiroAgent: + def test_launches_kiro_cli_binary(self, tmp_path: Path) -> None: + res = _run_kiro(tmp_path) + assert res.returncode == 0 + assert "[kiro-cli-iso] running in isolated env" in res.stderr + + def test_credentials_stripped(self, tmp_path: Path) -> None: + res = _run_kiro( + tmp_path, + extra_env={"GH_TOKEN": "ghp_secret", "ANTHROPIC_API_KEY": "sk-ant-secret"}, + ) + assert res.returncode == 0 + env = _parse_env(res.stdout) + assert "GH_TOKEN" not in env + assert "ANTHROPIC_API_KEY" not in env + + def test_passthrough_preserved(self, tmp_path: Path) -> None: + res = _run_kiro(tmp_path, extra_env={"HOME": "/home/testuser"}) + assert res.returncode == 0 + assert _parse_env(res.stdout).get("HOME") == "/home/testuser" + + def test_no_claude_settings_injection(self, tmp_path: Path) -> None: + res = _run_kiro(tmp_path, extra_args=["chat", "--no-interactive", "hello"]) + assert res.returncode == 0 + argv = _argv(res.stdout) + assert "--settings" not in argv + assert argv == ["chat", "--no-interactive", "hello"] + + def test_symlink_name_selects_kiro(self, tmp_path: Path) -> None: + res = _run_kiro(tmp_path, via_symlink=True) + assert res.returncode == 0 + assert "[kiro-cli-iso] running in isolated env" in res.stderr + + def test_missing_kiro_exits_127(self, tmp_path: Path) -> None: + result = subprocess.run( + [BASH, str(SCRIPT)], + env={ + "PATH": str(tmp_path), # no kiro-cli binary here + "HOME": "/tmp", + "USER": "testuser", + "SHELL": "/bin/sh", + "AGENT_ISO_AGENT": "kiro", + }, + cwd=str(tmp_path), + capture_output=True, + text=True, + ) + assert result.returncode == 127 + assert "kiro-cli-iso: 'kiro-cli' not found" in result.stderr diff --git a/tools/permission-audit/README.md b/tools/permission-audit/README.md index fd53f3f96..a5dca34d1 100644 --- a/tools/permission-audit/README.md +++ b/tools/permission-audit/README.md @@ -26,7 +26,7 @@ **Capability:** substrate:sandbox -**Harness:** Claude Code, OpenCode +**Harness:** Claude Code, OpenCode, Kiro Audit + atomically edit Claude Code's `permissions.allow[]` entries in `/.claude/settings.json` and `/.claude/settings.local.json`. @@ -60,6 +60,29 @@ uv run --project tools/permission-audit permission-audit audit-opencode opencode The Claude-only `apply` subcommand (atomic allow-list edits) has no OpenCode counterpart yet; `audit-opencode` is read-only. +**Kiro.** `audit-kiro` reads a Kiro CLI agent config +(`.kiro/agents/.json`). Kiro models permissions as an +allowlist/denylist rather than a per-command decision map +([Kiro shell tool](https://kiro.dev/docs/cli/reference/built-in-tools#execute-shell-commands)): +`allowedCommands` / `deniedCommands` are `\A..\z`-anchored regex, +deny-before-allow, with `denyByDefault` and `autoAllowReadonly`; and the +shell tool in `allowedTools` auto-approves everything (overriding +`toolsSettings`). Same intent, reported in JSON: + +- `shell-allow-all` — the shell tool (`shell` / `execute_bash` / `*` / + `@builtin`) is in `allowedTools` (every shell command auto-approved); +- `dangerous-allow` — an `allowedCommands` regex auto-approves a dangerous + family (unblocked by `deniedCommands`); +- `exfil-allow` — `aws` / `web_fetch` is blanket-allowed in `allowedTools`, + or `web_fetch.trusted` is a blanket `.*`. + +```bash +uv run --project tools/permission-audit permission-audit audit-kiro .kiro/agents/.json +``` + +Like `audit-opencode`, `audit-kiro` is read-only (no `apply`), and a +*missing* shell policy is not flagged — Kiro's default is to prompt. + ## Prerequisites - **Runtime:** Python 3.11+ run via `uv` (`uv sync` / `uv run`); the diff --git a/tools/permission-audit/src/permission_audit/cli.py b/tools/permission-audit/src/permission_audit/cli.py index 8bb35cebb..c7ad36767 100644 --- a/tools/permission-audit/src/permission_audit/cli.py +++ b/tools/permission-audit/src/permission_audit/cli.py @@ -40,6 +40,7 @@ audit_settings, ) from permission_audit.edit import apply_changes +from permission_audit.kiro import audit_kiro from permission_audit.opencode import audit_opencode @@ -136,6 +137,22 @@ def _cmd_audit_opencode(args: argparse.Namespace) -> int: return 1 if result.forbidden else 0 +def _cmd_audit_kiro(args: argparse.Namespace) -> int: + config_path = Path(args.config_path).resolve() + config = _read_opencode_config(config_path) + result = audit_kiro(config) + + output = { + "config_path": str(config_path), + "file_exists": config_path.exists(), + "harness": "kiro", + "forbidden": [asdict(f) for f in result.forbidden], + } + json.dump(output, sys.stdout, indent=2) + sys.stdout.write("\n") + return 1 if result.forbidden else 0 + + def _cmd_list_known(args: argparse.Namespace) -> int: output = { "forbidden_patterns": sorted(FORBIDDEN_PATTERNS), @@ -194,6 +211,13 @@ def _build_parser() -> argparse.ArgumentParser: p_audit_oc.add_argument("config_path", help="Path to opencode.json.") p_audit_oc.set_defaults(func=_cmd_audit_opencode) + p_audit_kiro = subparsers.add_parser( + "audit-kiro", + help="Audit a Kiro CLI agent-config (.kiro/agents/.json) for over-permissioning.", + ) + p_audit_kiro.add_argument("config_path", help="Path to a Kiro agent config JSON.") + p_audit_kiro.set_defaults(func=_cmd_audit_kiro) + p_known = subparsers.add_parser( "list-known", help="Print the canonical forbidden + recommended-by-family lists.", diff --git a/tools/permission-audit/src/permission_audit/kiro.py b/tools/permission-audit/src/permission_audit/kiro.py new file mode 100644 index 000000000..2c88bbd3d --- /dev/null +++ b/tools/permission-audit/src/permission_audit/kiro.py @@ -0,0 +1,170 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Audit a Kiro CLI agent-config for shell over-permissioning. + +Same intent as the Claude and OpenCode audits — flag config that +**auto-approves dangerous shell execution** — over Kiro's allowlist/denylist +model (`allowedTools` + `toolsSettings.shell`) rather than OpenCode's +per-command `permission.bash` decision map. + +Kiro resolution (): +the shell tool (`shell`/`execute_bash`/`execute_cmd`), `*`, or `@builtin` in +`allowedTools` auto-approves *every* shell command (overriding +`toolsSettings`); otherwise `deniedCommands` (regex, evaluated first) → deny, +`allowedCommands` (regex) → allow, `denyByDefault` → deny, else prompt. +`allowedCommands`/`deniedCommands` are `\\A..\\z`-anchored regex, matched here +with :func:`re.fullmatch`. + +Pure data + classification — no I/O. The CLI layer reads the file and renders. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass, field + +from permission_audit.opencode import DANGEROUS_COMMANDS + +SHELL_TOOL_NAMES = frozenset({"shell", "execute_bash", "execute_cmd"}) +WILDCARD_TOOL_NAMES = frozenset({"*", "@builtin"}) +EXFIL_TOOLS: tuple[tuple[frozenset[str], str], ...] = ( + (frozenset({"web_fetch", "webfetch"}), "auto-approves fetching arbitrary URLs (exfiltration)"), + (frozenset({"aws", "use_aws"}), "auto-approves AWS CLI calls (cloud credentials)"), +) +_BLANKET_TRUSTED = frozenset({".*", ".+", "^.*$", "(.*)"}) + + +@dataclass(frozen=True) +class KiroFinding: + """A single Kiro permission finding. + + `kind` is one of ``shell-allow-all`` (the shell tool is auto-approved via + `allowedTools`), ``dangerous-allow`` (an `allowedCommands` rule + auto-approves a dangerous family), or ``exfil-allow`` (a + credential/exfiltration surface is auto-approved). + """ + + severity: str # always "forbidden" today + kind: str + detail: str + json_pointer: str + + +@dataclass +class KiroAuditResult: + forbidden: list[KiroFinding] = field(default_factory=list) + + @property + def has_findings(self) -> bool: + return bool(self.forbidden) + + +def _shell_blanket_tool(allowed_tools: list) -> str | None: + for t in allowed_tools: + if isinstance(t, str) and (t in SHELL_TOOL_NAMES or t in WILDCARD_TOOL_NAMES): + return t + return None + + +def _regex_fullmatch_any(patterns: object, command: str) -> str | None: + if not isinstance(patterns, list): + return None + for pat in patterns: + if not isinstance(pat, str): + continue + try: + if re.fullmatch(pat, command): + return pat + except re.error: + continue + return None + + +def effective_shell_decision(shell_cfg: dict, command: str) -> tuple[str, str | None]: + """Resolve `command` to ``(decision, matched_pattern)`` — deny before allow.""" + denied = _regex_fullmatch_any(shell_cfg.get("deniedCommands"), command) + if denied is not None: + return "deny", denied + allowed = _regex_fullmatch_any(shell_cfg.get("allowedCommands"), command) + if allowed is not None: + return "allow", allowed + if shell_cfg.get("denyByDefault"): + return "deny", None + return "ask", None + + +def audit_kiro(config: dict) -> KiroAuditResult: + """Classify a Kiro agent-config dict for dangerous auto-approval.""" + result = KiroAuditResult() + + allowed_tools = config.get("allowedTools") or [] + if not isinstance(allowed_tools, list): + allowed_tools = [] + tools_settings = config.get("toolsSettings") + tools_settings = tools_settings if isinstance(tools_settings, dict) else {} + shell_cfg = tools_settings.get("shell") + shell_cfg = shell_cfg if isinstance(shell_cfg, dict) else {} + + blanket = _shell_blanket_tool(allowed_tools) + if blanket is not None: + result.forbidden.append( + KiroFinding( + "forbidden", + "shell-allow-all", + f"the shell tool is auto-approved via allowedTools ({blanket!r}) — every shell " + "command runs with no gate, overriding toolsSettings. Gate it with " + "toolsSettings.shell (denyByDefault + a scoped allowedCommands allowlist).", + ".allowedTools", + ) + ) + else: + seen: set[str] = set() + for command, label in DANGEROUS_COMMANDS: + decision, matched = effective_shell_decision(shell_cfg, command) + if decision == "allow" and matched is not None and matched not in seen: + seen.add(matched) + result.forbidden.append( + KiroFinding( + "forbidden", + "dangerous-allow", + f"{label} is auto-approved by allowedCommands rule {matched!r}.", + f".toolsSettings.shell.allowedCommands[{matched!r}]", + ) + ) + + for names, why in EXFIL_TOOLS: + hit = next((t for t in allowed_tools if isinstance(t, str) and t in names), None) + if hit is not None: + result.forbidden.append( + KiroFinding("forbidden", "exfil-allow", f"{hit!r} {why}.", ".allowedTools") + ) + + web_fetch = tools_settings.get("web_fetch") + if isinstance(web_fetch, dict): + for pat in web_fetch.get("trusted") or []: + if isinstance(pat, str) and pat in _BLANKET_TRUSTED: + result.forbidden.append( + KiroFinding( + "forbidden", + "exfil-allow", + f"web_fetch.trusted blanket-allows every URL ({pat!r}).", + ".toolsSettings.web_fetch.trusted", + ) + ) + + return result diff --git a/tools/permission-audit/tests/test_kiro.py b/tools/permission-audit/tests/test_kiro.py new file mode 100644 index 000000000..4792b3096 --- /dev/null +++ b/tools/permission-audit/tests/test_kiro.py @@ -0,0 +1,114 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for the Kiro CLI permission audit.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from permission_audit.cli import main +from permission_audit.kiro import audit_kiro, effective_shell_decision + + +def _kinds(config: dict) -> list[str]: + return [f.kind for f in audit_kiro(config).forbidden] + + +def test_empty_config_is_clean(): + # Kiro's default prompts for shell — no config is safe. + assert not audit_kiro({}).has_findings + + +def test_safe_denybydefault_allowlist_is_clean(): + config = {"toolsSettings": {"shell": {"denyByDefault": True, "allowedCommands": ["git status", "ls .*"]}}} + assert not audit_kiro(config).has_findings + + +def test_shell_in_allowed_tools_is_forbidden(): + r = audit_kiro({"allowedTools": ["read", "shell"]}) + assert [f.kind for f in r.forbidden] == ["shell-allow-all"] + assert r.forbidden[0].json_pointer == ".allowedTools" + + +def test_execute_bash_alias_forbidden(): + assert _kinds({"allowedTools": ["execute_bash"]}) == ["shell-allow-all"] + + +def test_wildcard_and_builtin_forbidden(): + assert _kinds({"allowedTools": ["*"]}) == ["shell-allow-all"] + assert _kinds({"allowedTools": ["@builtin"]}) == ["shell-allow-all"] + + +def test_blanket_shell_reported_once_not_per_command(): + r = audit_kiro({"allowedTools": ["shell"]}) + assert len(r.forbidden) == 1 + + +def test_dangerous_allowed_command_flagged(): + config = {"toolsSettings": {"shell": {"allowedCommands": ["sudo .*", "git status"]}}} + r = audit_kiro(config) + kinds = [f.kind for f in r.forbidden] + assert "dangerous-allow" in kinds + assert any("sudo" in f.detail for f in r.forbidden) + + +def test_deny_before_allow_wins_no_flag(): + config = {"toolsSettings": {"shell": {"allowedCommands": ["git .*"], "deniedCommands": ["git push .*"]}}} + r = audit_kiro(config) + assert not any("git push" in f.detail for f in r.forbidden) + + +def test_aws_tool_forbidden(): + assert _kinds({"allowedTools": ["aws"]}) == ["exfil-allow"] + + +def test_web_fetch_tool_forbidden(): + assert _kinds({"allowedTools": ["web_fetch"]}) == ["exfil-allow"] + + +def test_web_fetch_blanket_trusted_forbidden(): + r = audit_kiro({"toolsSettings": {"web_fetch": {"trusted": [".*"]}}}) + assert [f.kind for f in r.forbidden] == ["exfil-allow"] + + +def test_effective_shell_decision_deny_before_allow(): + cfg = {"allowedCommands": ["git .*"], "deniedCommands": ["git push .*"]} + assert effective_shell_decision(cfg, "git status") == ("allow", "git .*") + assert effective_shell_decision(cfg, "git push origin main") == ("deny", "git push .*") + assert effective_shell_decision({}, "ls -la") == ("ask", None) + assert effective_shell_decision({"denyByDefault": True}, "ls -la") == ("deny", None) + + +def test_cli_audit_kiro_denies_and_exits_1(tmp_path: Path, capsys): + cfg = tmp_path / "agent.json" + cfg.write_text(json.dumps({"allowedTools": ["shell"]})) + rc = main(["audit-kiro", str(cfg)]) + assert rc == 1 + out = json.loads(capsys.readouterr().out) + assert out["harness"] == "kiro" + assert out["forbidden"][0]["kind"] == "shell-allow-all" + + +def test_cli_audit_kiro_clean_exits_0(tmp_path: Path, capsys): + cfg = tmp_path / "agent.json" + cfg.write_text(json.dumps({"toolsSettings": {"shell": {"denyByDefault": True}}})) + rc = main(["audit-kiro", str(cfg)]) + assert rc == 0 + out = json.loads(capsys.readouterr().out) + assert out["forbidden"] == [] diff --git a/tools/sandbox-lint/README.md b/tools/sandbox-lint/README.md index 3e4a9e0be..f7d0add65 100644 --- a/tools/sandbox-lint/README.md +++ b/tools/sandbox-lint/README.md @@ -22,7 +22,7 @@ **Capability:** substrate:sandbox -**Harness:** Claude Code, OpenCode +**Harness:** Claude Code, OpenCode, Kiro Lints `.claude/settings.json` against the shipped baseline at `tools/sandbox-lint/expected.json`, and against the security @@ -47,6 +47,25 @@ to `"allow"`, no rule may auto-approve a dangerous command family uv run --project tools/sandbox-lint sandbox-lint --opencode opencode.json ``` +**Kiro.** `--kiro .kiro/agents/.json` lints a Kiro CLI agent +config. Kiro's shell posture is an allowlist/denylist rather than a +per-command decision map +([Kiro shell tool](https://kiro.dev/docs/cli/reference/built-in-tools#execute-shell-commands)): +`allowedCommands` / `deniedCommands` are full-string-anchored regex, +deny is evaluated before allow, `denyByDefault` is the native +allowlist-safe posture, and Kiro's default (no config) is to prompt — +so, unlike OpenCode, a *missing* policy is not flagged. The invariants: +the shell tool (`shell` / `execute_bash` / `*` / `@builtin`) must not be +in `allowedTools` (that auto-approves every shell command and overrides +`toolsSettings`), no `allowedCommands` regex may auto-approve a dangerous +family (and stay unblocked by `deniedCommands`), `aws` / `web_fetch` must +not be blanket-allowed in `allowedTools`, and `web_fetch.trusted` must +not be a blanket `.*`. + +```bash +uv run --project tools/sandbox-lint sandbox-lint --kiro .kiro/agents/.json +``` + ## Prerequisites - **Runtime:** Python 3.11+ run via `uv` (stdlib only, no third-party deps). diff --git a/tools/sandbox-lint/src/sandbox_lint/__init__.py b/tools/sandbox-lint/src/sandbox_lint/__init__.py index 611d8f2e1..3a3ab3b88 100644 --- a/tools/sandbox-lint/src/sandbox_lint/__init__.py +++ b/tools/sandbox-lint/src/sandbox_lint/__init__.py @@ -279,6 +279,21 @@ def _lint_opencode(config_path: Path) -> int: return 1 +def _lint_kiro(config_path: Path) -> int: + """Lint a Kiro CLI agent-config permission policy (invariants only).""" + from sandbox_lint.kiro import check_kiro_invariants + + config = _load_json(config_path) + errors = check_kiro_invariants(config) + if not errors: + print(f"sandbox-lint: OK ({config_path} permission policy satisfies the Kiro invariants)") + return 0 + print(f"sandbox-lint: Kiro permission-policy violations in {config_path}:", file=sys.stderr) + for e in errors: + print(f" - {e}", file=sys.stderr) + return 1 + + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="sandbox-lint", @@ -311,8 +326,21 @@ def main(argv: list[str] | None = None) -> int: "config (invariants only — no baseline diff)." ), ) + parser.add_argument( + "--kiro", + type=Path, + default=None, + metavar="KIRO_AGENT_JSON", + help=( + "Lint a Kiro CLI agent-config (.kiro/agents/.json) permission " + "policy against the Kiro security invariants instead of the Claude " + "Code sandbox config (invariants only — no baseline diff)." + ), + ) args = parser.parse_args(argv) + if args.kiro is not None: + return _lint_kiro(args.kiro) if args.opencode is not None: return _lint_opencode(args.opencode) diff --git a/tools/sandbox-lint/src/sandbox_lint/kiro.py b/tools/sandbox-lint/src/sandbox_lint/kiro.py new file mode 100644 index 000000000..239cd7541 --- /dev/null +++ b/tools/sandbox-lint/src/sandbox_lint/kiro.py @@ -0,0 +1,148 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Security invariants for a Kiro CLI agent-config permission policy. + +Like the OpenCode lint, this asserts safety properties (no baseline diff) — +an adopter's `.kiro/agents/.json` is their own. The intent is identical +to the Claude Code and OpenCode lints: the config must not **auto-approve +dangerous shell execution or exfiltration surfaces**. + +Kiro's permission model differs in shape from OpenCode's per-command +`permission.bash` decision map. Kiro resolves a shell command through +(): + +1. If the shell tool (`shell` / `execute_bash` / `execute_cmd`), `*`, or + `@builtin` is in `allowedTools`, **every** shell command is auto-approved + with no gate (and this overrides `toolsSettings`). +2. Otherwise, per command: `deniedCommands` regex (evaluated first) → deny; + `allowedCommands` regex → allow; `autoAllowReadonly` + read-only → allow; + `denyByDefault` → deny; else → **ask** (prompt). + +`allowedCommands` / `deniedCommands` are full-string-anchored regex (`\\A..\\z`, +no look-around), so a command is matched with :func:`re.fullmatch`. + +Unlike OpenCode, a **missing** shell policy is *not* flagged: Kiro's default is +to prompt for `shell`, which is already safe. The danger is only in configs +that add auto-approval. `autoAllowReadonly` is not probed — Kiro classifies +read-only-ness itself and none of the dangerous families below are read-only. +""" + +from __future__ import annotations + +import re +from typing import Any + +from sandbox_lint.opencode import DANGEROUS_COMMANDS + +# Tool ids that, in `allowedTools`, auto-approve every shell command. +SHELL_TOOL_NAMES = frozenset({"shell", "execute_bash", "execute_cmd"}) +WILDCARD_TOOL_NAMES = frozenset({"*", "@builtin"}) + +# Non-shell tools whose presence in `allowedTools` is an exfiltration / +# credential-access risk when auto-approved. +EXFIL_TOOLS: tuple[tuple[frozenset[str], str], ...] = ( + (frozenset({"web_fetch", "webfetch"}), "auto-approves fetching arbitrary URLs (exfiltration channel)"), + (frozenset({"aws", "use_aws"}), "auto-approves AWS CLI calls (cloud credentials)"), +) + + +def _shell_blanket_allowed(allowed_tools: list[Any]) -> bool: + return any(t in SHELL_TOOL_NAMES or t in WILDCARD_TOOL_NAMES for t in allowed_tools if isinstance(t, str)) + + +def _regex_fullmatch_any(patterns: Any, command: str) -> str | None: + """Return the first pattern that fully matches `command`, or None. + + Mirrors Kiro's `\\A..\\z`-anchored regex semantics via ``re.fullmatch``. + Invalid patterns are skipped (Kiro would reject them at load time). + """ + if not isinstance(patterns, list): + return None + for pat in patterns: + if not isinstance(pat, str): + continue + try: + if re.fullmatch(pat, command): + return pat + except re.error: + continue + return None + + +def effective_shell_decision(shell_cfg: dict[str, Any], command: str) -> tuple[str, str | None]: + """Resolve `command` to (decision, matched_pattern) — deny before allow.""" + denied = _regex_fullmatch_any(shell_cfg.get("deniedCommands"), command) + if denied is not None: + return "deny", denied + allowed = _regex_fullmatch_any(shell_cfg.get("allowedCommands"), command) + if allowed is not None: + return "allow", allowed + if shell_cfg.get("denyByDefault"): + return "deny", None + return "ask", None + + +def check_kiro_invariants(config: dict[str, Any]) -> list[str]: + """Return a list of invariant violations; empty means the policy is safe.""" + errors: list[str] = [] + + allowed_tools = config.get("allowedTools") or [] + if not isinstance(allowed_tools, list): + allowed_tools = [] + tools_settings = config.get("toolsSettings") + tools_settings = tools_settings if isinstance(tools_settings, dict) else {} + shell_cfg = tools_settings.get("shell") + shell_cfg = shell_cfg if isinstance(shell_cfg, dict) else {} + + # #2 — blanket auto-approve of the shell tool via allowedTools. + if _shell_blanket_allowed(allowed_tools): + errors.append( + 'allowedTools: must not auto-approve the shell tool (contains "shell"/' + '"execute_bash"/"*"/"@builtin") — every shell command runs with no gate, ' + "and this overrides toolsSettings.shell. Remove it and gate shell via " + "toolsSettings.shell (denyByDefault + a scoped allowedCommands allowlist)." + ) + else: + # #4 — specific allowedCommands rules that auto-approve a dangerous family. + seen: set[str] = set() + for command, label in DANGEROUS_COMMANDS: + decision, matched = effective_shell_decision(shell_cfg, command) + if decision == "allow" and matched is not None and matched not in seen: + seen.add(matched) + errors.append( + f"toolsSettings.shell.allowedCommands: must not auto-approve {label} " + f"(rule {matched!r} matches and is not denied)" + ) + + # #5 — exfil / credential tools blanket-allowed via allowedTools. + for names, why in EXFIL_TOOLS: + hit = next((t for t in allowed_tools if isinstance(t, str) and t in names), None) + if hit is not None: + errors.append(f"allowedTools: must not contain {hit!r} — it {why}; gate it instead") + + # web_fetch.trusted must not blanket-allow every URL. + web_fetch = tools_settings.get("web_fetch") + if isinstance(web_fetch, dict): + for pat in web_fetch.get("trusted") or []: + if isinstance(pat, str) and pat in (".*", ".+", "^.*$", "(.*)"): + errors.append( + f"toolsSettings.web_fetch.trusted: must not be a blanket {pat!r} — " + "it auto-allows fetching any URL (exfiltration channel)" + ) + + return errors diff --git a/tools/sandbox-lint/tests/test_kiro.py b/tools/sandbox-lint/tests/test_kiro.py new file mode 100644 index 000000000..c46069d40 --- /dev/null +++ b/tools/sandbox-lint/tests/test_kiro.py @@ -0,0 +1,120 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""Tests for the Kiro CLI permission-policy invariants of sandbox-lint.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from sandbox_lint import main +from sandbox_lint.kiro import check_kiro_invariants + + +def test_empty_config_ok() -> None: + # Kiro's default is to prompt for shell — a config with no shell policy is + # safe, unlike OpenCode where a missing permission is flagged. + assert check_kiro_invariants({}) == [] + + +def test_safe_denybydefault_allowlist_ok() -> None: + config = { + "toolsSettings": { + "shell": { + "denyByDefault": True, + "allowedCommands": ["git status", "git fetch", "ls .*"], + } + } + } + assert check_kiro_invariants(config) == [] + + +def test_shell_in_allowed_tools_flagged() -> None: + errs = check_kiro_invariants({"allowedTools": ["read", "shell"]}) + assert len(errs) == 1 + assert "allowedTools" in errs[0] and "shell" in errs[0] + + +def test_execute_bash_alias_in_allowed_tools_flagged() -> None: + assert check_kiro_invariants({"allowedTools": ["execute_bash"]}) + + +def test_wildcard_allowed_tools_flagged() -> None: + assert check_kiro_invariants({"allowedTools": ["*"]}) + assert check_kiro_invariants({"allowedTools": ["@builtin"]}) + + +def test_dangerous_allowed_command_flagged() -> None: + config = {"toolsSettings": {"shell": {"allowedCommands": ["sudo .*", "git status"]}}} + errs = check_kiro_invariants(config) + assert any("sudo" in e for e in errs) + + +def test_curl_allowed_command_flagged() -> None: + config = {"toolsSettings": {"shell": {"allowedCommands": ["curl .*"]}}} + errs = check_kiro_invariants(config) + assert any("curl" in e for e in errs) + + +def test_deny_before_allow_wins_no_flag() -> None: + # allowedCommands opens git broadly, but deniedCommands (evaluated first) + # covers the only dangerous git sample — so no git finding. + config = { + "toolsSettings": { + "shell": { + "allowedCommands": ["git .*"], + "deniedCommands": ["git push .*"], + } + } + } + errs = check_kiro_invariants(config) + assert not any("git push" in e for e in errs) + + +def test_aws_in_allowed_tools_flagged() -> None: + errs = check_kiro_invariants({"allowedTools": ["aws"]}) + assert any("aws" in e for e in errs) + + +def test_web_fetch_in_allowed_tools_flagged() -> None: + errs = check_kiro_invariants({"allowedTools": ["web_fetch"]}) + assert any("web_fetch" in e for e in errs) + + +def test_web_fetch_blanket_trusted_flagged() -> None: + config = {"toolsSettings": {"web_fetch": {"trusted": [".*"]}}} + errs = check_kiro_invariants(config) + assert any("web_fetch.trusted" in e for e in errs) + + +def test_cli_kiro_clean(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + cfg = tmp_path / "agent.json" + cfg.write_text(json.dumps({"toolsSettings": {"shell": {"denyByDefault": True}}})) + rc = main(["--kiro", str(cfg)]) + assert rc == 0 + assert "OK" in capsys.readouterr().out + + +def test_cli_kiro_violation(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None: + cfg = tmp_path / "agent.json" + cfg.write_text(json.dumps({"allowedTools": ["shell"]})) + rc = main(["--kiro", str(cfg)]) + assert rc == 1 + assert "violations" in capsys.readouterr().err diff --git a/tools/spec-loop/README.md b/tools/spec-loop/README.md index ae18f24dc..12ce9c3a0 100644 --- a/tools/spec-loop/README.md +++ b/tools/spec-loop/README.md @@ -5,7 +5,7 @@ **Capability:** substrate:framework-dev -**Harness:** Claude Code, Codex, Cursor, Gemini CLI, OpenCode +**Harness:** Claude Code, Codex, Cursor, Gemini CLI, OpenCode, Kiro A spec-driven build loop for this framework, in the general [Ralph](https://ghuntley.com/ralph/) style (run a fresh agent context diff --git a/tools/spec-loop/lib.sh b/tools/spec-loop/lib.sh index dfd2bec27..eaa95299a 100644 --- a/tools/spec-loop/lib.sh +++ b/tools/spec-loop/lib.sh @@ -110,6 +110,12 @@ spec_loop_launch_agent() { --yolo \ ${model_args[@]+"${model_args[@]}"} \ --prompt "$(cat "$prompt_file")" & + elif [ "$harness" = "kiro" ]; then + # Kiro CLI headless: positional prompt to `kiro-cli chat --no-interactive`. + # The model is selected by the chosen agent config (.kiro/agents), not a + # per-invocation flag, and Kiro has no stream-json output mode here. + "$agent" chat --no-interactive \ + "$(cat "$prompt_file")" & else local verbose_args=() [ "$output_format" = "stream-json" ] && verbose_args=(--verbose) diff --git a/tools/spec-loop/loop.sh b/tools/spec-loop/loop.sh index 8b763497b..fb5da88a5 100755 --- a/tools/spec-loop/loop.sh +++ b/tools/spec-loop/loop.sh @@ -106,6 +106,7 @@ case "${SPEC_LOOP_HARNESS:-$(basename "$AGENT")}" in *cursor*) HARNESS=cursor ;; *gemini*) HARNESS=gemini ;; *opencode*) HARNESS=opencode ;; + *kiro*) HARNESS=kiro ;; *) HARNESS=claude ;; esac if [ -n "${SPEC_LOOP_MODEL:-}" ]; then diff --git a/tools/spec-loop/specs/spec-loop-runner.md b/tools/spec-loop/specs/spec-loop-runner.md index 1efdbd437..197deb33b 100644 --- a/tools/spec-loop/specs/spec-loop-runner.md +++ b/tools/spec-loop/specs/spec-loop-runner.md @@ -20,7 +20,7 @@ acceptance: - Open PRs and local work-item branches are passed into plan/build prompts as in-flight work so the loop does not duplicate already-built items. - The headless-agent harness contract is explicit for Claude Code, Codex, - Cursor, Gemini CLI, and OpenCode. + Cursor, Gemini CLI, OpenCode, and Kiro. - The update marker `tools/spec-loop/.last-sync` is owned by the runner, not by the prompt. --- @@ -109,6 +109,7 @@ sandbox, and stop without pushing or opening a PR. | Cursor | positional prompt to `cursor agent --print` or `cursor-agent --print` | `--workspace "$ROOT"` | `--force --trust` | `--model` | `--output-format` | external sandbox and Cursor policy | | Gemini CLI | `--prompt ""` | launched from repo root | `--yolo` | `--model` | default CLI output | external sandbox and Gemini policy | | OpenCode | positional prompt to `opencode run` | launched from repo root | `--auto` | `--model` | `--format json` for stream JSON | external sandbox and OpenCode policy | +| Kiro | positional prompt to `kiro-cli chat` | launched from repo root | `--no-interactive` | agent config (`.kiro/agents`) | plain text only | external sandbox and the agent-guard `--kiro` hook | `SPEC_LOOP_AGENT` chooses the CLI. `SPEC_LOOP_HARNESS` chooses the invocation convention and defaults from the agent basename. Adding a new @@ -135,7 +136,7 @@ updating `loop.sh` in the same change. branch context. 4. Update prompts receive incremental-scope guidance from `.last-sync` when present, but the runner remains the only writer of `.last-sync`. -5. Claude Code, Codex, Cursor, Gemini CLI, and OpenCode are documented in +5. Claude Code, Codex, Cursor, Gemini CLI, OpenCode, and Kiro are documented in the headless harness matrix and implemented in `loop.sh`. 6. Operator docs and prompts agree on branch naming: build uses bare `` branches; update uses `sync-specs-` branches. diff --git a/tools/spec-loop/tests/test_runner_fixtures.sh b/tools/spec-loop/tests/test_runner_fixtures.sh index 20dda4e77..a647500a4 100755 --- a/tools/spec-loop/tests/test_runner_fixtures.sh +++ b/tools/spec-loop/tests/test_runner_fixtures.sh @@ -114,6 +114,14 @@ test_harness_command_construction() { assert_contains "$TMPDIR_TEST/codex.log" "<--json>" assert_contains "$TMPDIR_TEST/codex.log" "<->" assert_contains "$TMPDIR_TEST/codex.log" "stdin:PROMPT BODY" + + make_fake_agent "$TMPDIR_TEST/kiro-cli" "$TMPDIR_TEST/kiro.log" + spec_loop_launch_agent kiro "$TMPDIR_TEST/kiro-cli" /repo "$TMPDIR_TEST/prompt.md" "" text + wait "$SPEC_LOOP_AGENT_PID" + assert_contains "$TMPDIR_TEST/kiro.log" "" + assert_contains "$TMPDIR_TEST/kiro.log" "<--no-interactive>" + assert_contains "$TMPDIR_TEST/kiro.log" "" + assert_not_contains "$TMPDIR_TEST/kiro.log" "<--model>" } test_last_sync_marker_helpers() {