Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions docs/system-specs/modules/taskrunner.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,56 @@ On gateway restart, any task with `status == "running"` is automatically transit
- `cli_server.py` constructs the standalone `kirocrew run TASK.md` runner without an approval callback. Use `force_approval`, not `requires_approval`, for an action that must not execute unattended.
- The dashboard supplies the callback and renders Approve/Deny controls in the project detail view.

### Spec-declared approval mode

A spec may DECLARE its intended approval mode in leading YAML frontmatter, so a
trusted plan carries that intent in version control:

```markdown
---
approval: auto
---
# Task: refactor the widget
```

**A declaration is not a grant.** The server derives auto-approval from the
`auto_approve` field of the launching human's request body and from nothing else
— `_gate_auto_approve` is still the only decision point, and the declaration is
never OR-ed into it. So a spec obtained from an untrusted source (repo, chat,
download) cannot disable the launching human's approval prompts, which is what a
content-derived grant did: the provenance gate checks *who launched*, not
*whether that human consented to unattended execution*, so a dashboard launch of
someone else's spec passed the gate on the launcher's own standing.

What the declaration does instead is reach the person who owns the checkbox:

- `task_planner.spec_declares_auto()` reports it. `True` only for a literal
`auto` (case-insensitive) as a top-level frontmatter key; the grammar is
`frontmatter.TASK_SPEC`.
- `POST /api/taskrunner/plan` returns it as read-only `declared_approval`, at no
extra disk read — `spec_content` is already in memory at plan time.
- The project detail view renders it beside the existing auto-approve checkbox
and leaves that box **unchecked**. The trust decision stays a deliberate click
at Execute time, matching the existing rule that a planned or resumed run
shows unchecked because its live grant was torn down.

Because the reading is advisory rather than authorizing, it needs none of the
hardening a content-derived grant needed. Two consequences worth stating:

- Only frontmatter counts. A bare top-of-file `approval:` line is not a
declaration, so there is no window of arbitrary document text to scan and no
code-fence or HTML-markup class to defend against.
- **Duplicate keys fail closed.** `parse_frontmatter` returns a dict, so two
`approval:` keys would otherwise be resolved silently by position — and
whichever declaration a human reads first need not be the one the parser
honored. `TASK_SPEC` sets `reject_duplicate_keys`, so two or more occurrences
report "not declared".

Operators who want an unattended *background* source still use the separate
explicit opt-in `hooks.auto_approve_sources` (e.g. add `"taskrunner"`), which is
consent scoped to a source rather than to a file's contents. `force_approval`
gates block regardless.

## Parallel Execution

Parallel groups are throttled to prevent resource exhaustion from simultaneous kiro-cli cold starts. Each kiro-cli cold start spawns MCP server child processes, so concurrent tasks multiply startup pressure.
Expand Down
11 changes: 10 additions & 1 deletion src/kiro_crew/dashboard/handlers/taskrunner.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from kiro_crew.dashboard.state import DashboardState
from kiro_crew.security import is_sensitive_path, redact_credentials, redact_exfiltration_urls
from kiro_crew.task_planner import plan_to_yaml
from kiro_crew.task_planner import plan_to_yaml, spec_declares_auto

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -565,6 +565,15 @@ async def api_taskrunner_plan(request: web.Request) -> web.Response:
[s.index for s in group]
for group in state.task_runner._group_parallel_tasks(run.tasks, set())
],
# READ-ONLY report of what the spec DECLARED, so the reviewing human
# sees it before pressing Execute and can grant unattended execution
# through their OWN request body (the auto-approve checkbox). The
# server never reads this back: `/execute` derives auto-approve from
# `auto_approve` alone, so a spec cannot grant itself anything.
#
# Costs no disk read — `spec_content` was captured in memory at plan
# time and is the same snapshot the run executes from.
"declared_approval": spec_declares_auto(run.spec_content or ""),
}
)

Expand Down
49 changes: 47 additions & 2 deletions src/kiro_crew/frontmatter.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Single home for hand-rolled SKILL.md frontmatter parsing.

Four backend callers parse ``key: value`` frontmatter from markdown, and each
Five backend callers parse ``key: value`` frontmatter from markdown, and each
historically carried its own copy of the scanner. The copies drifted: they
disagree on whether the opening fence may carry trailing text or leading
whitespace, whether an indented ``key: value`` line is a field or prose,
Expand All @@ -18,7 +18,16 @@
:data:`SKILL_LOADER` rather than owning a dialect: what the preview shows
must match what the skills loader computes after install.

A FIFTH consumer lives outside Python and cannot be reached from this list by
A fifth caller — a task spec's declared approval mode
(``task_planner.spec_declares_auto``) — arrived with its own scanner and was
folded in as :data:`TASK_SPEC` rather than kept separate, which is what
retired that copy's bespoke code-fence and HTML-markup machinery: a delimited
frontmatter region needs neither. It adds no quirk the mirror below must
track: :data:`TASK_SPEC` is a new dialect, and its one new axis
(``reject_duplicate_keys``) is off for every pre-existing dialect, so
:data:`SKILL_LOADER`'s grammar is byte-for-byte what it was.

A SIXTH consumer lives outside Python and cannot be reached from this list by
import: the skill editor's frontmatter splicer
(``website/src/components/SkillForm.tsx``) MIRRORS :data:`SKILL_LOADER`'s
grammar in TypeScript, because it must never write a value this reader would
Expand Down Expand Up @@ -90,6 +99,15 @@ class FrontmatterDialect:
# True: the first occurrence of a duplicate key wins (single-value lookup
# semantics). False: the last occurrence wins (dict-overwrite semantics).
first_key_wins: bool = False
# True: a key declared TWICE OR MORE is dropped from the result entirely,
# so a contradiction reads as absent. Where `first_key_wins` picks a winner
# by position, this refuses to pick one at all — for a caller whose field
# is a security-relevant declaration, whichever line a human reads first
# need not be the line a positional rule honors, so the safe reading of two
# conflicting declarations is "nothing was declared" (GPT 5.6, PR #2129).
# Orthogonal to `first_key_wins`: rejection is decided over the raw
# occurrence count, so it holds under either positional rule.
reject_duplicate_keys: bool = False
# Resolve a bare block-scalar indicator value (see
# BLOCK_SCALAR_INDICATORS) from the blank-or-indented lines that follow
# it, via fold_block_scalar. Dialects without this store the indicator
Expand Down Expand Up @@ -135,6 +153,23 @@ class FrontmatterDialect:
resolve_block_scalars=True,
)

# ``task_planner.spec_declares_auto`` — a task spec's declared approval mode.
# Leading whitespace before the opener is tolerated (specs are hand-authored, so
# a stray blank first line must not silently mean "declared nothing"); indented
# keys are prose, so an ``approval:`` nested under another mapping is not a
# top-level declaration; quotes are stripped so ``approval: "auto"`` reads the
# same as bare. Block scalars are deliberately NOT resolved: resolving them would
# let ``approval: |`` fold an indented continuation line into a declaration, and
# for this caller the indicator character is simply not the declared word.
# Duplicate keys are REJECTED rather than resolved by position — see
# `reject_duplicate_keys`.
TASK_SPEC = FrontmatterDialect(
extraction="leading_ws_fence",
indent_policy="reject_indented",
strip_quotes=True,
reject_duplicate_keys=True,
)


def fold_block_scalar(indicator: str, block: list[str]) -> str:
"""Resolve a YAML block scalar's indented lines into a single value.
Expand Down Expand Up @@ -266,6 +301,10 @@ def _extract_block(
def _parse_block_lines(lines: list[str], dialect: FrontmatterDialect) -> dict[str, str]:
"""Scan block lines into a field dict under *dialect*'s line rules."""
fields: dict[str, str] = {}
# Keys seen more than once, collected only when the dialect rejects them.
# Recorded during the scan and applied after it, because the second
# occurrence is what makes the FIRST one unusable too.
duplicated: set[str] = set()
i = 0
while i < len(lines):
line = lines[i]
Expand Down Expand Up @@ -294,7 +333,13 @@ def _parse_block_lines(lines: list[str], dialect: FrontmatterDialect) -> dict[st
value = fold_block_scalar(value, block)
elif dialect.strip_quotes:
value = value.strip("\"'")
# Checked BEFORE the positional rule below, which would otherwise skip
# the line that proves the key was declared twice.
if dialect.reject_duplicate_keys and key in fields:
duplicated.add(key)
if dialect.first_key_wins and key in fields:
continue
fields[key] = value
for key in duplicated:
del fields[key]
return fields
45 changes: 45 additions & 0 deletions src/kiro_crew/task_planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from typing import TYPE_CHECKING, Any

from kiro_crew.executors import run_in_embed_pool
from kiro_crew.frontmatter import TASK_SPEC, parse_frontmatter
from kiro_crew.hooks import TOOL_DENY
from kiro_crew.llm_helpers import _extract_json_of_type
from kiro_crew.platform.context import redact_log_via_context
Expand Down Expand Up @@ -52,6 +53,50 @@ def auto_name(spec_content: str, spec_path: str = "") -> str:
return ""


# ── Spec-declared approval mode ──


def spec_declares_auto(spec_content: str) -> bool:
"""Report whether a spec's frontmatter DECLARES ``approval: auto``.

A spec may state its intended approval mode in a leading YAML frontmatter
fence, so a trusted plan can carry that intent in version control::

---
approval: auto
---
# Task: refactor the widget

This REPORTS a declaration and GRANTS nothing. Unattended execution is
granted only by the launching human's own request body — the dashboard's
auto-approve checkbox, routed through ``_gate_auto_approve`` — and the server
never derives that grant from spec bytes. The declaration is surfaced
pre-launch so the human can act on it; a spec obtained from an untrusted
source therefore cannot disable that human's approval prompts on its own
authority (GPT 5.6 / First-Principles / Design review, PR #2129).

That separation is also what keeps this function small. While a declaration
was a grant, every reading of it was an authorization decision, so the
scanner had to defend the whole surface a directive could hide in — code
fences, HTML comments, wrapper elements, a bounded top-of-file window.
Reporting intent carries no such burden: the reading is advisory, the region
is delimited, and the grammar is :data:`~kiro_crew.frontmatter.TASK_SPEC`.

``True`` only for a literal ``auto`` under a top-level ``approval`` key.
Anything else is ``False``: no frontmatter, an unterminated fence, another
value, an indented occurrence, or TWO conflicting ``approval:`` keys
(rejected, never resolved by position).

The VALUE is case-insensitive; the KEY is not, so ``Approval: auto`` declares
nothing. That asymmetry is the whole module's behavior — no dialect folds key
case (:func:`~kiro_crew.frontmatter.parse_frontmatter` keeps ``Name`` verbatim
for ``SKILL_LOADER`` too) — and it errs toward per-action prompting, so it is
left as-is rather than special-cased here.
"""
declared = parse_frontmatter(spec_content or "", TASK_SPEC).get("approval", "")
return declared.strip().lower() == "auto"


# ── Parallel Task Grouping ──


Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading