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
67 changes: 63 additions & 4 deletions docs/system-specs/modules/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -2003,13 +2003,72 @@ grammar admits no quotes, so a quote can only sit in the target group;
refusing the whole token for it had mislabelled the shape), while fragment
tokens (open quote state) still poison the split protectively. Quoted operator characters are data and none of this fires. A segment
whose CUMULATIVE quote/escape state is still open at its end continues into the
next line — bash line continuation (`\<newline>` vanishes) and quoted newlines
splice words across the newline segment boundary, so `origin ma\` + newline +
next line — bash line continuation (`\<newline>` vanishes) splices words across
the newline segment boundary, so `origin ma\` + newline +
`in` pushes `main` while no scanned token spells it — and lands on the ungated
sentinel (the `ma$in` posture); a mid-segment open whose quote closes before
sentinel (the `ma$in` posture). A literal newline INSIDE quotes is word data
and does not end the segment. Of the two layers that read such a command, only
one is the deny authority: the per-line reading judges every push line exactly
as a single-line command, dropping its ungated sentinel only where the
whole-text walk enters the line inside a quote that closes before the push and
ends its word (the tail of a quoted multi-line word; a fragment glued to the
closing quote keeps the word going) and judging the code after that close in full,
and only when that word is inert data: its owner is `git commit`, the one owner
the reported failure names, it is a `'…'` or `"…"` word (a `$'…'` or `$"…"`
word is decoded or translated by bash, so it is never data), a double-quoted
word holds no `$`, backtick or `\` (so no expansion such as `$1` can hand bash
an option), and the rest
of the command passes one closed allowlist: with its
other quoted words masked (an empty one, or one opening with `-`, stays an option
bash dequotes and fails the check, as does an inert word that opens with `-` or
is glued to other text), only word characters, spaces, tabs, newlines and
`. / : = @ % + , - ; &` remain,
and every segment is `cd <dir>` or a `git` verb (`add`, `commit`, `push`) whose
every flag is on that verb's lowercase allowlist (`commit -m`/`-q`, `push -u`/`-q`,
no `add` flag, no `git -c` setting), where only `-m` takes
a value (so no `--author`/`--date` can swallow a `-m`), a commit carrying its
message (so no `git config`,
`--edit`, `-c` or bare `--amend` opens an editor on the text; option scanning
stops at `--`, so a pathspec after it, even one named `--no-edit`, is not read as
a flag and cannot satisfy that message rule; a `--` that is the value of `-m`
ends nothing, so an `-e` after it is still checked). The deny tiers also judge
kiro-cli's `Running: <command>` title, so this proof reads through ONE exact
leading `running: ` (`_SHELL_TITLE_PREFIX`, the constant `_exception_applies`
uses) and the title gets the verdict of the command it displays; a second
prefix, one without its space, or one later in the text stays a command word.
Adding a verb, owner or
flag to `_INERT_FLAGS` widens an
allow on the push gate, so it needs a matching row in `_INERT_ORACLE_ROWS`, which
the real-bash oracle (`test_no_command_real_bash_runs_as_a_protected_push_is_allowed`)
requires to run a protected push that stays denied;
`test_every_inert_allowlist_entry_has_a_real_bash_oracle_row` fails on an entry
without one. The list holds
only the flags issue #12631's refused commands use, each with its row. When a line
of a refused multi-line command starts inside a quote and the command with each
`git` command after an unquoted separator (with any `env` or assignment prefix)
moved onto its own line clears the floor, read over every nested shell payload as
the floor reads it, the
reason says the push can go on its own line in place of the substitution reason,
which that command does not have. A pipe, redirect
(`exec > f` included), expansion, glob, brace, comment, function or alias
definition, or `source`d helper therefore keeps the sentinel, as does a word
`trap` decodes or expands,
so a quote the walk gets wrong cannot hide a protected, bare or unverifiable
push. The construct list only narrows ALLOWS, by limiting where a quoted newline
is kept: a heredoc, a command, parameter or process substitution, a backtick, a
real `#` comment, or any backslash-newline outside a plain single quote (where
bash reads every character literally) keeps the split at every newline, because
the walk models none of their bodies and bash deletes a backslash-newline before
it reads quotes or openers; no denial depends on it. A merged segment that
does not parse as a push (quoted text that merely mentions one) is read line by
line instead; a mid-segment open whose quote closes before
segment end stays on the disableable fallback, because in-segment joining can
only fuse whitespace into a word (never a valid refname) and the pieces stay
visible to the superset scan. The invariant
visible to the superset scan. Any change to `_iter_shell_chars` or
`_split_push_command_segments` must keep
`test_every_per_line_deny_survives_the_whole_text_reading` green: it is the
required gate that the whole-text reading keeps every deny a line earns alone,
with the construct list on and off. The invariant
has two different strengths, stated honestly: the OPTION-ARITY axis fails
protective by construction (an unrecognised option lands in the fallback), while
the SHELL-SYNTAX axis rests on the metacharacter inventory being complete — a
Expand Down
2 changes: 2 additions & 0 deletions src/kiro_crew/security/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@
"_emit_deny_exception_event": "deny_gate",
"_emit_push_allow_event": "deny_gate",
"_inline_interpreter_bindings": "deny_gate",
"_own_line_fix_applies": "deny_gate",
"_perm_verb_mechanism_for": "deny_gate",
"_perm_verb_mention_narrows": "deny_gate",
"_schedule_push_allow_audit": "deny_gate",
Expand Down Expand Up @@ -947,6 +948,7 @@ def __delattr__(self, name: str) -> None:
_emit_deny_exception_event,
_emit_push_allow_event,
_inline_interpreter_bindings,
_own_line_fix_applies,
_perm_verb_mechanism_for,
_perm_verb_mention_narrows,
_schedule_push_allow_audit,
Expand Down
1 change: 1 addition & 0 deletions src/kiro_crew/security/_exports.py
Original file line number Diff line number Diff line change
Expand Up @@ -428,6 +428,7 @@
"_own_host_names",
"_own_host_seed",
"_own_interface_addresses",
"_own_line_fix_applies",
"_partial_jwt_tail",
"_path_in_home_dirs",
"_path_resolve_clock",
Expand Down
1 change: 1 addition & 0 deletions src/kiro_crew/security/argv_floor.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@
_dequote_token,
_ends_argv,
_glob_could_expand_to,
_inert_quoted_line_heads,
_is_mint_verb,
_is_self_program,
_iter_shell_chars,
Expand Down
20 changes: 19 additions & 1 deletion src/kiro_crew/security/argv_floor_git.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
_REDIRECT_START_RE,
_cut_at_operator,
_dequote_token,
_inert_quoted_line_heads,
_iter_shell_chars,
_nested_shell_payloads,
_push_option_matches,
Expand Down Expand Up @@ -1014,13 +1015,20 @@ def _git_publish_floor_tags(text_lower: str) -> frozenset[str]:
"""
tags: set[str] = set()
saw_push = False
for command in _split_push_command_segments(text_lower):
pending = _split_push_command_segments(text_lower)
while pending:
command = pending.pop(0)
# ``_is_git_publish`` (not ``_git_push_args``) gates the checks so that
# glue-evasion forms — which do NOT tokenize to a clean ``git`` token —
# are still recognized as pushes and cannot slip past the ambiguity /
# fail-closed guards below.
if not _is_git_publish(command):
continue
# A kept quoted newline with no parseable push behind it (an ``echo`` or
# ``--body`` quoting a push line) is judged line by line, the conservative reading.
if "\n" in command and _git_push_args(command) is None:
pending[:0] = _split_push_command_segments(command, keep_quoted_newlines=False)
continue
saw_push = True
# Substitution / expansion glue anywhere in a push command makes it
# unverifiable (the shell fuses it into the verb or the target word).
Expand Down Expand Up @@ -1078,6 +1086,16 @@ def _git_publish_floor_tags(text_lower: str) -> frozenset[str]:
# A push was detected upstream (e.g. glue-evasion ``git_push``) but no
# clean ``push`` segment survived splitting — deny to be safe.
tags.add(_GIT_PUBLISH_UNGATED)
if "\n" in text_lower:
# The per-line reading still judges every push line as a single-line command,
# so a quote the walk gets wrong cannot hide one. Its ungated sentinel is
# dropped only behind an inert quoted head; the rest of the line is judged in full.
for line, close in _inert_quoted_line_heads(text_lower, _is_git_publish):
line_tags = set(_git_publish_floor_tags(line))
if close >= 0 and not _is_git_publish(line[: close + 1]):
line_tags.discard(_GIT_PUBLISH_UNGATED)
line_tags |= _git_publish_floor_tags(line[close + 1 :])
tags |= line_tags
return frozenset(tags)


Expand Down
6 changes: 1 addition & 5 deletions src/kiro_crew/security/denied_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@
BUILTIN_DENY_PATTERNS,
)
from .diagnostics import RefusalDiagnostic, annotate_refusal, refusal_diagnostic
from .shell_normalizer import _SHELL_ACTIVE_CHARS
from .shell_normalizer import _SHELL_ACTIVE_CHARS, _SHELL_TITLE_PREFIX

if TYPE_CHECKING:
from collections.abc import Iterable
Expand Down Expand Up @@ -488,10 +488,6 @@ def _exception_eligible(view: str) -> bool:
return not _SHELL_ACTIVE_CHARS.intersection(view)


#: kiro-cli's display prefix on a shell call's title, lowercased like the views.
_SHELL_TITLE_PREFIX = "running: "


def _exception_applies(view: str, exceptions: list[str]) -> bool:
"""Whether one of *exceptions* exonerates the lowercased *view*.

Expand Down
40 changes: 38 additions & 2 deletions src/kiro_crew/security/deny_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@

if TYPE_CHECKING:
from collections.abc import Callable
from types import ModuleType


def _schedule_push_allow_audit(command: str) -> None:
Expand Down Expand Up @@ -416,6 +417,35 @@ def _inline_interpreter_bindings(text: str) -> str:
return _INTERP_IDENT_RE.sub(lambda m: bindings.get(m.group(0), m.group(0)), text)


def _own_line_fix_applies(_argv: ModuleType, lower: str) -> bool:
"""Whether a line starts inside a quote and moving each ``git`` command after a
separator (behind any ``env``/assignment prefix) onto its own line clears the
push floor, so the hint is true.

Only an ACTIVE separator is moved: a ``; git push`` inside the quoted message
is message text, and splitting there would leave it in every resubmission. The
rewrite is judged the way the floor judges a resubmission, over the source of
every nested shell payload, so a ``bash -c`` push that stays unverifiable on its
own line does not earn the hint."""
_shell = _submodule("shell_normalizer")
if not any(entry[0] for entry in _shell._quoted_line_entries(lower)[1:]):
return False
try:
active = {step.offset for step in _shell._iter_shell_chars(lower) if step.active}
rewritten = re.sub(
r"[ \t]*(;|&&?|\|\|)[ \t]*(?=(?:env\s+)?(?:\w+=\S*\s+)*git\s)",
lambda m: "\n" if active.issuperset(range(*m.span(1))) else m.group(0),
lower,
)
sources = _argv._shell_payload_sources(rewritten)
except Exception:
# Advice only: a walk that fails cannot show the fix works, so leave it out.
return False
return not any(
_argv._git_publish_floor_tags(source) for source in sources if _argv._is_git_publish(source)
)


def is_denied(
tool_name: str,
extra_patterns: list[str] | None = None,
Expand Down Expand Up @@ -644,8 +674,14 @@ def _reason(
return _reason(
ungated_pattern,
"Matched structurally on the command's argv, not by the pattern text above: "
"shell substitution or expansion fuses text into the push target, so the "
"destination branch cannot be determined before the push runs.",
+ (
"the push shares a line with a multi-line quoted argument, so its "
"destination branch cannot be read before the push runs. Put the push "
"on its own line."
if _own_line_fix_applies(_argv, lower)
else "shell substitution or expansion fuses text into the push target, so "
"the destination branch cannot be determined before the push runs."
),
rule="git-publish-target-unverifiable",
component="git-publish-floor",
)
Expand Down
3 changes: 3 additions & 0 deletions src/kiro_crew/security/shell_normalizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@
_PROCESS_SUBSTITUTION_OPENERS,
_SHELL_ACTIVE_CHARS,
_SHELL_ASSIGN_RE,
_SHELL_TITLE_PREFIX,
_SHELL_WRAPPER_CHARS,
_VAR_USE_RE,
_arms_case_context,
Expand All @@ -90,6 +91,7 @@
_dequote_token,
_ends_argv,
_fold_line_continuations,
_inert_quoted_line_heads,
_is_computed_value,
_is_shell_variable_reference,
_iter_shell_chars,
Expand All @@ -99,6 +101,7 @@
_push_option_matches,
_push_token_redirection,
_push_token_shell_read,
_quoted_line_entries,
_redirect_consumes_next,
_resolve_param_defaults,
_resolved_word_view,
Expand Down
Loading
Loading