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
2 changes: 1 addition & 1 deletion docs/system-specs/modules/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -579,7 +579,7 @@ Two mechanisms make "the split changed nothing for a caller" a tested claim rath
- `diagnostics.py` — what a refusal says about ITSELF: the refusal-diagnostic record, the character-class census behind it, and the one appender every tier uses. A refusal that names only a matcher cannot be acted on — the agent receiving it cannot tell which tier decided, cannot tell where in its own command the decision landed, and so cannot tell a true positive from a matcher firing on text position. Three properties make it safe to put in front of a model and in an audit record. It carries no payload bytes: the matched region is reported as offsets plus a character-class census, because a refusal is the one message guaranteed to concern content the policy judged sensitive, so echoing the match would make the explanation the leak. Its identifiers are closed: the rule id and the component name are screened against an identifier pattern and replaced by a placeholder otherwise, so the format is structurally incapable of quoting a command rather than merely careful not to. And its cost is bounded: the census reads a fixed maximum of the span while the reported length stays true, because the one tier that refuses WITHOUT scanning refuses precisely because its subject is too large to walk on the event loop. It imports nothing from the package, which is what lets both the keystone and the catalog tier reach it without an import-time cycle; it and `vocabulary.py` and `helpers.py` are peers at the bottom of the order.
- `vocabulary.py` — the bottom of the dependency order: the product's own name in the two spellings the matchers need, one that matches the name anywhere in a token and one that matches it only as a whole program name, plus the kill programs that select their target by name. Pure vocabulary — no predicate and no verdict, so nothing here can decide anything. It sits below the reader because two tiers read it and neither owns it: the reader consults it to work out what a computed word expands to, and the argv-structural floor above the reader matches the same name in command position. Holding the spellings here is what keeps that dependency one-way instead of the reader depending on the floor and the floor on the reader, an import-time cycle. It imports nothing from the package.
- `helpers.py` — the foundation: the public prompt-injection screen over the shared vocabulary, and the resource-limit policy reader with the `preexec_fn` builder it feeds (see [resource-protection.md](../../architecture/resource-protection.md)). It imports nothing from the package, which is what makes it the bottom of the dependency order and keeps the split acyclic. The resource limits sit here rather than with a matcher because they bound a *spawn*, not a path or a command.
- `shell_normalizer.py` — the shell text reader every tier that judges a command line goes through, and its attribution layer: argv attribution, the glob reading of a program name, the expansion shapes whose value cannot be known without running the line, and the top of the reader — the public path normalizer, the tokenizer and the quote-literal decoder behind it, the nested-payload extractor and the payload walk that descends through every literal wrapper, and the local-assignment resolver that substitutes a variable with a literal assigned on the same line, together with the per-line assignment-resolved views the gate re-scans and the argument-position rule that keeps an argument shaped like an assignment from being read as one. It also holds the word and shape layer that reads a native-shell line into words and each word into a path shape and the change-directory vocabulary. The rest of the reader is four owners below it, which never import it: `shell_normalizer_walk.py`, the lexical layer (statement and word splitting, the quote and escape state machine, line continuations, the structural matches a substitution, a backtick or a `case` arm needs, substitution bodies, and the word views that peel brackets, parameter defaults and empty substitutions off a word); `shell_normalizer_redirects.py`, where program text rides outside argv (redirections, here-strings, heredocs, stdin carriers and the `-c` carrier forms); `shell_normalizer_escapes.py`, printer and ANSI-C escape decoding; and `shell_normalizer_programs.py`, the program vocabulary attribution reads. `vocabulary.py` keeps the product's own name and the by-name kill verbs; a new program-name set the reader attributes goes to `shell_normalizer_programs.py`. A name a test patches stays in the module with every reader of it, which is what keeps the payload walk and its helpers, the data-consumer exemption, the stdin-program reading, the quote-literal decoder and the glob family here. `shell_normalizer.py` re-imports every name a caller imports from it. It decides nothing, and imports only `vocabulary.py`, the layer below, and its four owners — the tiers depend on the reader, never the reverse. The reader deliberately **over**-approximates: one that under-approximates blinds every tier at once, and no OS sandbox sees shell text. Narrowing therefore belongs at the site that decides, never here. The whole-program recognizer and the mint-verb predicate sit here rather than with the argv floor that also reads them: each is a thin test over this module's own basename and operand readers, so a module below the reader cannot hold either without taking those readers down with it, and placing either in the floor would make the reader depend on the floor and the floor on the reader, an import-time cycle. They recognize a word rather than decide anything about it. The name spellings they match are one layer further down, in `vocabulary.py`.
- `shell_normalizer.py` — the shell text reader every tier that judges a command line goes through, and its attribution layer: argv attribution, the glob reading of a program name, the expansion shapes whose value cannot be known without running the line, and the top of the reader — the public path normalizer, the tokenizer and the quote-literal decoder behind it, the nested-payload extractor and the payload walk that descends through every literal wrapper, and the local-assignment resolver that substitutes a variable with a literal assigned on the same line, together with the per-line assignment-resolved views the gate re-scans and the argument-position rule that keeps an argument shaped like an assignment from being read as one. It also holds the word and shape layer that reads a native-shell line into words and each word into a path shape and the change-directory vocabulary. The rest of the reader is five owners below it, which never import it: `shell_normalizer_walk.py`, the lexical layer (statement and word splitting, the quote and escape state machine, line continuations, comments and command position, and the word views that peel brackets, parameter defaults and empty substitutions off a word); `shell_normalizer_span.py`, the span layer above it (where a substitution, a backtick, a `${...}` expansion or a `case` arm ends, and the substitution bodies); `shell_normalizer_redirects.py`, where program text rides outside argv (redirections, here-strings, heredocs, stdin carriers and the `-c` carrier forms); `shell_normalizer_escapes.py`, printer and ANSI-C escape decoding; and `shell_normalizer_programs.py`, the program vocabulary attribution reads. `vocabulary.py` keeps the product's own name and the by-name kill verbs; a new program-name set the reader attributes goes to `shell_normalizer_programs.py`. A name a test patches stays in the module with every reader of it, which is what keeps the payload walk and its helpers, the data-consumer exemption, the stdin-program reading, the quote-literal decoder and the glob family here. `shell_normalizer.py` re-imports every name a caller imports from it. It decides nothing, and imports only `vocabulary.py`, the layer below, and its five owners — the tiers depend on the reader, never the reverse. The reader deliberately **over**-approximates: one that under-approximates blinds every tier at once, and no OS sandbox sees shell text. Narrowing therefore belongs at the site that decides, never here. The whole-program recognizer and the mint-verb predicate sit here rather than with the argv floor that also reads them: each is a thin test over this module's own basename and operand readers, so a module below the reader cannot hold either without taking those readers down with it, and placing either in the floor would make the reader depend on the floor and the floor on the reader, an import-time cycle. They recognize a word rather than decide anything about it. The name spellings they match are one layer further down, in `vocabulary.py`.
- `paths.py` — the keystone sensitive-path floor, as a mirror facade
(`kiro_crew._facade_mirror`) that keeps the read/write path gates, their matchers
(`_path_in_home_dirs`, the keystone publish-artifact check), the scan-size ceiling and
Expand Down
14 changes: 8 additions & 6 deletions src/kiro_crew/security/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,7 +621,7 @@
"_ShellChar": "shell_normalizer_walk",
"_ShellWalk": "shell_normalizer_walk",
"_VAR_USE_RE": "shell_normalizer_walk",
"_backtick_closer": "shell_normalizer_walk",
"_backtick_closer": "shell_normalizer_span",
"_continuation_width": "shell_normalizer_walk",
"_cut_at_operator": "shell_normalizer_walk",
"_debracket": "shell_normalizer_walk",
Expand All @@ -631,7 +631,7 @@
"_is_computed_value": "shell_normalizer_walk",
"_is_shell_variable_reference": "shell_normalizer_walk",
"_iter_shell_chars": "shell_normalizer_walk",
"_matching_close_paren": "shell_normalizer_walk",
"_matching_close_paren": "shell_normalizer_span",
"_push_option_matches": "shell_normalizer_walk",
"_push_token_redirection": "shell_normalizer_walk",
"_push_token_shell_read": "shell_normalizer_walk",
Expand All @@ -643,7 +643,7 @@
"_split_push_command_segments": "shell_normalizer_walk",
"_split_shell_words": "shell_normalizer_walk",
"_strip_redirect": "shell_normalizer_walk",
"_substitution_bodies": "shell_normalizer_walk",
"_substitution_bodies": "shell_normalizer_span",
"_substitution_depth_delta": "shell_normalizer_walk",
"_substitution_program": "shell_normalizer_walk",
# text_redaction
Expand Down Expand Up @@ -1274,6 +1274,11 @@ def __delattr__(self, name: str) -> None:
_stdin_program_text,
_stdin_redirect_carriers,
)
from kiro_crew.security.shell_normalizer_span import ( # noqa: F401
_backtick_closer,
_matching_close_paren,
_substitution_bodies,
)
from kiro_crew.security.shell_normalizer_walk import ( # noqa: F401
_AMBIGUOUS_EXPANSION_RE,
_CMD_SEPARATOR_RE,
Expand All @@ -1297,7 +1302,6 @@ def __delattr__(self, name: str) -> None:
_SHELL_VAR_RE,
_SHELL_WRAPPER_CHARS,
_VAR_USE_RE,
_backtick_closer,
_continuation_width,
_cut_at_operator,
_debracket,
Expand All @@ -1307,7 +1311,6 @@ def __delattr__(self, name: str) -> None:
_is_computed_value,
_is_shell_variable_reference,
_iter_shell_chars,
_matching_close_paren,
_push_option_matches,
_push_token_redirection,
_push_token_shell_read,
Expand All @@ -1321,7 +1324,6 @@ def __delattr__(self, name: str) -> None:
_split_push_command_segments,
_split_shell_words,
_strip_redirect,
_substitution_bodies,
_substitution_depth_delta,
_substitution_program,
)
Expand Down
4 changes: 4 additions & 0 deletions src/kiro_crew/security/argv_floor.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@
warm_own_host_names,
)
from kiro_crew.security.argv_floor_self import ( # noqa: F401
_CAPPED_KILL_RE,
_DEV_MODE_CONFIRM_FLAG,
_RAW_ASSIGNMENT_RE,
_SELF_CLOUD_DESTRUCTIVE_VERBS,
Expand Down Expand Up @@ -282,13 +283,15 @@
_SHELL_WRAPPER_CHARS,
_argv_programs,
_backtick_closer,
_capped_span_possible,
_cut_at_operator,
_data_consumer_exempt,
_debracket,
_decode_printf_escapes,
_decode_shell_quoted_literals,
_dequote_token,
_ends_argv,
_fold_line_continuations,
_glob_could_expand_to,
_is_mint_verb,
_is_self_program,
Expand All @@ -308,6 +311,7 @@
_split_shell_words,
_strip_redirect,
_substitution_bodies,
_substitution_body_entries,
_substitution_depth_delta,
_xargs_here_string_rebuild,
)
Expand Down
31 changes: 29 additions & 2 deletions src/kiro_crew/security/argv_floor_self.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,13 @@
_SHELL_WRAPPER_CHARS,
_argv_programs,
_backtick_closer,
_capped_span_possible,
_data_consumer_exempt,
_debracket,
_decode_printf_escapes,
_decode_shell_quoted_literals,
_ends_argv,
_fold_line_continuations,
_glob_could_expand_to,
_is_mint_verb,
_is_self_program,
Expand All @@ -55,6 +58,7 @@
_shell_join_continuations,
_shell_payload_walk,
_substitution_bodies,
_substitution_body_entries,
_substitution_depth_delta,
)
from .vocabulary import _KILL_BY_NAME_PROGRAMS, _SELF_NAME_RE
Expand Down Expand Up @@ -255,6 +259,11 @@ def _shell_payload_sources(text_lower: str) -> "list[str]":

_SELF_FLOOR_QUOTE_JUNK_RE = re.compile(r"[\"'\\\\]")

#: A ``kill`` / ``pkill`` / ``killall`` WORD, read after quote junk comes off
#: (``k''ill``), for the capped-span fail-closed in :func:`_is_self_kill`; not
#: ``killed``, ``--kill-after`` or ``kill.txt``.
_CAPPED_KILL_RE = re.compile(r"(?<![a-z0-9_.-])p?kill(?:all)?(?![a-z0-9_.-])")


def _self_floor_can_fire(text_lower: str) -> bool:
"""Cheap O(n) necessary condition for the self-protection floor predicates.
Expand Down Expand Up @@ -586,7 +595,7 @@ def record_body(body: str) -> None:
# bash parses the body in a fresh context, so the span is
# proven from the slice at NEUTRAL state -- and the walk
# resumes with the OUTER state carried across the jump.
rel, proven = _matching_close_paren(source[off + 2 :], 0)
rel, proven = _matching_close_paren(source[off + 2 :])
body = source[off + 2 : off + 1 + rel] if proven else source[off + 2 :]
# An EMPTY substitution expands to NOTHING, so the word
# CONTINUES across it -- ``kill$()`` runs ``kill`` (the same
Expand Down Expand Up @@ -649,7 +658,7 @@ def record_body(body: str) -> None:
break
if step.active:
if ch in "<>" and source.startswith("(", off + 1):
rel, proven = _matching_close_paren(source[off + 2 :], 0)
rel, proven = _matching_close_paren(source[off + 2 :])
fresh_word = not word and not open_word
if not fresh_word and not _kill_prefix_keeps_anchor(words, word):
glued = True
Expand Down Expand Up @@ -730,6 +739,24 @@ def _is_self_kill(text_lower: str) -> bool:
# holds, so a miss skips both descents.
if not _self_floor_can_fire(text_lower):
return False
# A span past the nesting cap is read in shallow pieces, so the frame holding
# a ``kill`` and the one holding its ``$(pgrep -f kiro${x:-crew})`` can part
# and neither names the target. Refuse a kill once the span reader really
# capped a span in some frame (Opus). The opener count only gates that walk:
# 32 flat ``${A}`` nest nothing and are no reason to refuse (Opus, Security
# Scope). The word is matched on the raw text and on its ANSI-C decoding, so
# ``$'\x6b'ill`` and ``$'\153'ill`` read as ``kill`` too (bolichen97).
if _capped_span_possible(text_lower) and any(
_CAPPED_KILL_RE.search(_SELF_FLOOR_QUOTE_JUNK_RE.sub("", view))
for view in (text_lower, _decode_shell_quoted_literals(text_lower).lower())
):
for source in _shell_payload_sources(text_lower):
if not _capped_span_possible(source):
continue
seen: list[bool] = []
_substitution_body_entries(_fold_line_continuations(source), capped_seen=seen)
if seen:
return True
for tokens in _self_token_frames(text_lower):
programs = _argv_programs(tokens)
# Once per FRAME, not once per trigger token: see ``_is_credential_mint``.
Expand Down
2 changes: 1 addition & 1 deletion src/kiro_crew/security/rm_floor.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ def _segments(command: str, track: bool = True) -> "list[str]":
if step.state == 2 and text.startswith("$(", step.offset):
# bash reads "$(…)" from an unquoted start, then the double quote resumes
body = text[step.offset + 2 :]
close, proven = _shell._matching_close_paren(body, 0)
close, proven = _shell._matching_close_paren(body)
out.extend(_segments(body[: close - 1 if proven else close], track))
seg.append(_SUBST)
in_redirect = False
Expand Down
Loading
Loading