Skip to content
Merged
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
6 changes: 5 additions & 1 deletion src/kiro_crew/dashboard/handlers/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -2033,7 +2033,11 @@ async def api_models(request: web.Request) -> web.Response:
try:
env = {**os.environ}
env["PATH"] = augmented_path(env.get("PATH", ""))
_resolve_ssh_auth_sock(env)
# OFF the loop: the resolver globs /tmp/ssh-*/agent.* and stats
# every hit, so its latency scales with the /tmp entry count. Its
# sibling wrapper's contract states it must never run on the event
# loop, and this endpoint is polled every 8s while degraded.
await asyncio.to_thread(_resolve_ssh_auth_sock, env)
# The Docker entrypoint removes credentials from the long-lived
# gateway environment. This fixed-argv child is the official
# kiro-cli and KIRO_API_KEY is its own model credential, so settle
Expand Down
30 changes: 26 additions & 4 deletions src/kiro_crew/dashboard/handlers/terminal.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from kiro_crew.dashboard.origin import check_origin, mark_audit_claimed
from kiro_crew.executors import discovery_executor, subprocess_executor
from kiro_crew.hooks import validate_file_path
from kiro_crew.sandbox import _PYTHON_ENV_PREFIXES
from kiro_crew.security import (
is_sensitive_path,
redact_credentials,
Expand Down Expand Up @@ -468,6 +469,28 @@ def _is_bash_shell(shell: str) -> bool:
_READY_PREV_VAR = "KIROCREW_TERMINAL_READY_PREV"


def _pty_child_env(extra: dict[str, str]) -> dict[str, str]:
"""Build the interactive shell's environment: the gateway environment plus
*extra*, minus Kiro Crew's own Python startup variables.

``PYTHONPATH``/``PYTHONHOME``/``PYTHONPYCACHEPREFIX`` are searched BEFORE a
venv's own site-packages, so leaking the gateway's copies makes a user's
Python 3.13 venv import Kiro Crew's 3.12 site-packages and its C extensions
fail to load. The agent surface strips them via
``sandbox.scrub_agent_subprocess_env``; this brings the terminal into line.

Only the Python prefixes are dropped. Unlike the agent spawn, this is the
user's own unsandboxed shell (see the spawn comment below), so
``SSH_AUTH_SOCK``, the AWS vars and the rest of the credential-bearing
environment must survive or git-over-SSH and the AWS CLI break in it.
"""
env = {**os.environ, **extra}
for key in list(env):
if any(key.startswith(prefix) for prefix in _PYTHON_ENV_PREFIXES):
del env[key]
return env


def _bash_ready_env(token: str) -> dict[str, str]:
"""Environment that makes a real login Bash report readiness at its prompt.

Expand Down Expand Up @@ -860,7 +883,7 @@ async def api_terminal_ws(request: web.Request) -> web.WebSocketResponse | web.R
cwd = _resolve_cwd(cfg, request.query.get("cwd"))
if not os.path.isdir(cwd):
cwd = os.path.expanduser("~")
env = {**os.environ, "KIROCREW_TERMINAL": "1"}
env = _pty_child_env({"KIROCREW_TERMINAL": "1"})
argv = [shell, "-NoLogo"] if "powershell" in shell.lower() else [shell]
try:
wp = WindowsPty(argv, cwd=cwd, env=env, cols=80, rows=24)
Expand Down Expand Up @@ -907,8 +930,7 @@ async def api_terminal_ws(request: web.Request) -> web.WebSocketResponse | web.R
struct.pack("HHHH", 24, 80, 0, 0),
)
cwd = _resolve_cwd(cfg, request.query.get("cwd"))
env = {
**os.environ,
env = _pty_child_env({
"TERM": "xterm-256color",
"KIROCREW_TERMINAL": "1",
# Export the shell actually being spawned (already resolved to
Expand All @@ -918,7 +940,7 @@ async def api_terminal_ws(request: web.Request) -> web.WebSocketResponse | web.R
# (vim's :sh, tmux default-shell) open the wrong one. POSIX
# branch only: PowerShell does not consult $SHELL.
"SHELL": shell,
}
})
# Security: intentionally unsandboxed — this is the user's own
# interactive terminal (like SSH), not agent-executed code.
# Auth is enforced at WS handshake via token_auth_middleware.
Expand Down
120 changes: 97 additions & 23 deletions src/kiro_crew/llm_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -886,6 +886,84 @@ def _collect(obj: object) -> None:
return results


# Longest single string the tool_input scan will attempt.
#
# CPython's ``re`` does not release the GIL for the duration of one match call,
# so the worker-thread hop below yields BETWEEN per-string scans but not WITHIN
# one: a single huge string holds the GIL inside one ``.search`` and the event
# loop cannot run its watchdog heartbeat for that whole time.
#
# The anchor rewrite in this change cut the constant but NOT the growth -- the
# scan is still superlinear in the length of one line. Measured on one dev box:
#
# 4 KiB 0.16s | 8 KiB 0.31s | 16 KiB 1.14s | 20 KiB 1.52s | 32 KiB 3.79s
#
# 16->32 KiB is 3.3x for 2x the input (~n^1.7), so extrapolation is the wrong
# instinct here and a ceiling has to be set from the measured curve. A CI runner
# under parallel load came in roughly an order of magnitude slower than this box,
# which is what sets the margin: 20 KiB is ~1.5s here and ~15s there, under the
# 25s loop watchdog on both.
#
# Exceeding it is FAIL-CLOSED: the call is denied, never skipped. A skip would
# convert a liveness bug into a security hole by letting unscanned input through
# the deny surface; a denial only refuses input we cannot prove safe, and is
# strictly better than the alternative it replaces, which was crashing the
# gateway and losing the whole turn.
#
# Known cost of that trade, and why the ceiling is an interim: a permission-gated
# write of a benign file larger than this lands its whole content in
# ``tool_input`` and is now refused. Chunked scanning cannot lift the ceiling on
# its own, because one branch of the pattern puts an unbounded ``.*`` between a
# verb and the path, so no chunk overlap preserves that distance. The durable fix
# is to stop running the SHELL-COMMAND matcher over fields that never carry a
# command. Tracked in https://github.com/kirodotdev/KiroCrew/issues/8053.
_MAX_SCANNABLE_TOOL_INPUT_CHARS = 20 * 1024


def _first_tool_input_denial(
strings: list[str],
denied_regexes: list[str] | None,
) -> tuple[str, str, str] | None:
"""Return the first tool_input denial among *strings*, or ``None``.

Pure, synchronous, and blocking: the three predicates are regex-heavy and
``_extract_tool_input_strings`` hands over EVERY string in the payload, so a
single long document body can occupy this loop for seconds. It therefore
runs on a worker thread (one hop for the whole loop, not one per string),
which keeps the event loop free BETWEEN strings -- not within one, because
``re`` holds the GIL for a whole match call. That is why each string is
length-checked against :data:`_MAX_SCANNABLE_TOOL_INPUT_CHARS` first, and an
oversized one is denied rather than scanned or skipped.

The tuple is ``(kind, reason, matched_string)`` where *kind* is
``"path"`` / ``"bash"`` / ``"regex"`` / ``"oversize"``. Mechanism
classification stays with the caller on the event loop, because it consults
the HookManager.
"""
for s in strings:
if len(s) > _MAX_SCANNABLE_TOOL_INPUT_CHARS:
# Fail closed: too long to scan inside the loop's liveness budget,
# so it cannot be shown safe and is refused.
return (
"oversize",
(
"Blocked: a tool_input string is too large to security-scan "
f"({len(s)} chars > {_MAX_SCANNABLE_TOOL_INPUT_CHARS} limit); "
"refused rather than left unscanned"
),
s[:64],
)
if is_sensitive_path(s):
return ("path", f"Blocked: sensitive path in tool_input: {s}", s)
_input_bash = is_sensitive_bash_command(s)
if _input_bash:
return ("bash", _input_bash, s)
_input_deny = is_denied(s, denied_regexes=denied_regexes)
if _input_deny:
return ("regex", _input_deny, s)
return None


# ── Tool Approval Policies ──


Expand Down Expand Up @@ -2046,29 +2124,25 @@ def _regex_deny_mechanism(probe: str, unconditional: str) -> str:
if _tool_input:
# Extract string values from JSON tool_input for path/command checking.
_input_strings = _extract_tool_input_strings(_tool_input)
for s in _input_strings:
if is_sensitive_path(s):
await provider.reject_tool(event.request_id)
_log(
"denied",
error=f"Blocked: sensitive path in tool_input: {s}",
metadata={"mechanism": "always_deny_input"},
)
return False
_input_bash = is_sensitive_bash_command(s)
if _input_bash:
await provider.reject_tool(event.request_id)
_log("denied", error=_input_bash, metadata={"mechanism": "always_deny_input"})
return False
_input_deny = is_denied(s, denied_regexes=_denied_regexes)
if _input_deny:
await provider.reject_tool(event.request_id)
_log(
"denied",
error=_input_deny,
metadata={"mechanism": _regex_deny_mechanism(s, "always_deny_input")},
)
return False
# Offloaded: the scan is regex-heavy over every string in the payload,
# so a large document body would block the event loop past its watchdog
# and take the gateway down. One hop for the whole loop.
_hit = await asyncio.to_thread(_first_tool_input_denial, _input_strings, _denied_regexes)
if _hit is not None:
_kind, _reason, _matched = _hit
await provider.reject_tool(event.request_id)
_log(
"denied",
error=_reason,
metadata={
"mechanism": (
_regex_deny_mechanism(_matched, "always_deny_input")
if _kind == "regex"
else "always_deny_input"
)
},
)
return False

if policy == ToolApprovalPolicy.HOOK_BASED and hooks:
tool_result = hooks.on_tool_call(
Expand Down
52 changes: 35 additions & 17 deletions src/kiro_crew/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -7439,18 +7439,26 @@ def _build_sensitive_regex() -> re.Pattern[str]:
# colon lists, comma/semicolon-joined args) — without the latter a
# ``FOO=bar:~/.aws/credentials`` or ``PATH=/x:~/.ssh/id_rsa`` token slips
# past the backstop while no verb branch fires either.
#
# The anchor is written ``(?:^|[\s'\"=:,;])`` with NO leading ``.*``: this
# pattern is only ever used via ``.search`` (see ``_get_sensitive_re``
# callers), which already retries at every offset, so a leading ``.*``
# matched nothing extra while making the scan quadratic in the longest
# line. Note ``\n`` is in the class, so a path at the start of a later
# line still matches even though ``.`` never crossed a newline anyway.
# Do NOT reintroduce ``.*`` here.
# (3) write-protected leaf: matched verb-INDEPENDENTLY too (same token
# anchor), so a quoted redirect (``> "$HOME/.../marker"``), ``cp``,
# ``python -c "open(...,'w')"`` or any novel write verb is still caught.
rf"(?:(?:{_READ_CMDS}.*|{_WRITE_CMDS}.*|{_SCRIPT_OPEN}.*|.*[<>|]\s*)"
rf"{sensitive_path}"
rf"|(?:^|.*[\s'\"=:,;]){sensitive_path}"
rf"|(?:^|.*[\s'\"=:,;]){write_protected_path}"
rf"|(?:^|[\s'\"=:,;]){sensitive_path}"
rf"|(?:^|[\s'\"=:,;]){write_protected_path}"
# (3b) publish artifacts of a keystone leaf -- the atomic-write temp and the lock
# sibling -- in both the POSIX and the Windows-native spelling. Verb-independent
# like (2)/(3): naming the artifact is the signal, so a redirect, a ``cp``, or an
# embedded ``open(...,'w')`` is caught without enumerating write verbs.
rf"|(?:^|.*[\s'\"=:,;]){artifact_path}"
rf"|(?:^|[\s'\"=:,;]){artifact_path}"
# (4) Windows-native spelling, verb-independent (same token anchor):
# covers quoted backslash paths AND embedded-script literals that the
# tokenizing passes cannot see. (5) the %APPDATA% / %LOCALAPPDATA%
Expand All @@ -7459,21 +7467,21 @@ def _build_sensitive_regex() -> re.Pattern[str]:
# (7) the distinctive leaves as a bare path SEGMENT, with no anchor at
# all, because branches (3) and (6) both fall to a ``cd`` plus a
# relative name.
rf"|(?:^|.*[\s'\"=:,;]){win_sensitive_path}"
rf"|(?:^|.*[\s'\"=:,;]){win_artifact_path}"
rf"|(?:^|.*[\s'\"=:,;]){appdata_sensitive_path}"
rf"|(?:^|.*[\s'\"=:,;]){localappdata_sensitive_path}"
rf"|(?:^|.*[\s'\"=:,;]){win_write_protected_path}"
rf"|(?:^|.*[\s'\"=:,;]){win_crew_var_leaf_path}"
rf"|(?:^|[\s'\"=:,;]){win_sensitive_path}"
rf"|(?:^|[\s'\"=:,;]){win_artifact_path}"
rf"|(?:^|[\s'\"=:,;]){appdata_sensitive_path}"
rf"|(?:^|[\s'\"=:,;]){localappdata_sensitive_path}"
rf"|(?:^|[\s'\"=:,;]){win_write_protected_path}"
rf"|(?:^|[\s'\"=:,;]){win_crew_var_leaf_path}"
# (8) ~/.kiro/agents (POSIX and Windows-native spelling, plus the
# ``$KIRO_HOME`` override), matched verb-INDEPENDENTLY with the same token
# anchor as (2)/(3): naming the dir is the signal, so ``curl -o``/``wget
# -O`` output-file writers, ``python -c "open(...,'w')"`` and any novel
# write verb are caught, not just an enumerated allowlist. Bash reads of
# the dir are blocked incidentally (harmless — no secret, Python readers
# only); tool-path reads stay allowed.
rf"|(?:^|.*[\s'\"=:,;]){agents_write_path}"
rf"|(?:^|.*[\s'\"=:,;]){win_agents_write_path}"
rf"|(?:^|[\s'\"=:,;]){agents_write_path}"
rf"|(?:^|[\s'\"=:,;]){win_agents_write_path}"
# (10) whisper weight FILENAMES, also with no anchor, because the digest the
# model store checks only binds the bytes if the name it then loads cannot be
# rewritten by a ``cd``-relative command.
Expand Down Expand Up @@ -12562,19 +12570,29 @@ def redact_credentials(text: str) -> tuple[str, list[str]]:
# This is the hot path — the alternation is 23 branches retried at nearly
# every position, and real text almost never contains a credential.
if _might_contain_credential(result):
for m in _CREDENTIAL_PATTERNS.finditer(result):
matched = m.group()
tag = _REDACTED_CREDENTIAL_TAG
result = result.replace(matched, tag, 1)

def _redact_one(m: re.Match[str]) -> str:
# Emit ONLY non-sensitive metadata (length). Do NOT slice any part of
# `matched` into the warning: `_CREDENTIAL_PATTERNS` matches the raw
# the match into the warning: `_CREDENTIAL_PATTERNS` matches the raw
# secret value itself (e.g. `ghp_…`, `sk-ant-…`), so even a short prefix
# is genuine plaintext key material — a fixed-length token prefix leaves
# ~12-16 secret chars in a 20-char slice. The warnings list is a
# redaction-subsystem output expected to be safe to log/surface, so it
# must carry no secret bytes. Mirrors the base64 / bare-secret branches
# below, which already log length only.
warnings.append(f"Redacted credential pattern ({len(matched)} chars)")
warnings.append(f"Redacted credential pattern ({len(m.group())} chars)")
return _REDACTED_CREDENTIAL_TAG

# ONE pass. `sub` walks the matches left-to-right exactly as `finditer`
# did and calls the replacer in that same order, so `warnings` is
# appended in an identical order with identical contents. The previous
# shape rebuilt the entire string per match via
# `result.replace(matched, tag, 1)` — O(n) per match, O(n²) overall on
# credential-dense text — and replaced the FIRST occurrence of the
# matched text rather than the span that actually matched. `sub` splices
# each matched span in place, which is both linear and positionally
# exact.
result = _CREDENTIAL_PATTERNS.sub(_redact_one, result)

# Passes 2 and 3 both scan the ORIGINAL `text` for runs of the base64
# alphabet, and they select the SAME spans: `[A-Za-z0-9+/]{40,}` is greedy and
Expand Down
43 changes: 43 additions & 0 deletions test/test_api_models_retry.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import asyncio
import json
import logging
import threading
from pathlib import Path
from types import SimpleNamespace
from typing import Any
Expand Down Expand Up @@ -430,3 +431,45 @@ def _refuse(argv, **kwargs):
}
# The remedy reaches the operator rather than a bare traceback.
assert any("not Linux" in r.getMessage() for r in caplog.records), caplog.text


def test_ssh_auth_sock_resolver_runs_off_the_event_loop(tmp_path):
"""``_resolve_ssh_auth_sock`` globs ``/tmp/ssh-*/agent.*`` and ``os.stat``s
every hit, so its latency scales with the ``/tmp`` entry count. The frontend
polls this endpoint every 8s while the model list is degraded, so an on-loop
call stalls chat, cron and the liveness heartbeat on exactly the host where
the probe is slowest. Its sibling wrapper's docstring states the rule: never
on the event loop. Same shape as
``test_acp_spawn_offload.py``'s resolver assertions.
"""
payload = json.dumps({"models": [{"model_name": "claude-opus-4.8"}]}).encode()
seen: list[threading.Thread] = []

def _probe(env):
seen.append(threading.current_thread())
return None

async def _drive():
loop_thread = threading.current_thread()
resp = await agents.api_models(_kiro_request(tmp_path))
return loop_thread, resp

with patch.object(agents.KiroCrewConfig, "load", return_value=_kiro_cfg()), patch(
"kiro_crew.acp.client._resolve_kiro_bin_for_spawn", return_value="/usr/bin/kiro-cli"
), patch("kiro_crew.acp.client._resolve_ssh_auth_sock", _probe), patch(
"kiro_crew.env.augmented_path", lambda p: p
), patch(
"kiro_crew.dashboard.handlers.agents.wrap_argv", _stub_wrap_argv
), patch(
"kiro_crew.dashboard.handlers.agents.cgroup_scope_argv", lambda argv: argv
), patch(
"kiro_crew.sandbox.resource_limit_preexec", lambda: None
), patch.object(
agents.asyncio, "create_subprocess_exec", return_value=_FakeProc(payload)
):
loop_thread, resp = _run(_drive())

assert resp.status == 200
assert seen, "_resolve_ssh_auth_sock was never called by api_models"
for thread in seen:
assert thread is not loop_thread, "ssh resolver ran on the event loop thread"
Loading
Loading