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
2 changes: 1 addition & 1 deletion docs/system-specs/modules/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -1672,7 +1672,7 @@ continue to apply.
- `_is_push_to_protected_branch()` splits the command with `_split_segments()` and validates **every** `push` segment / refspec (closing the `push origin feat && push origin main` bypass), normalizing `refs/heads/…` paths and `local:remote` refspecs; refspecs with shell/revision syntax (`$`, `` ` ``, `@{…}` — `_AMBIGUOUS_REFSPEC_RE`) are treated as ambiguous and denied. If a push was detected upstream but **no** clean segment parses, it denies to be safe.
- **Force push:** a force flag (`--force` / `-f` / `--force-with-lease`) does not by itself make a feature-branch push protected (force-push to a feature branch is normal PR/rebase workflow), but force-push to a *protected* branch is still blocked because the target check fires regardless of flags.
- **Self-protection (argv-structural floor):** the controls that stop the agent disabling its own controls — credential minting, process termination, the dev-mode attestation flag, the ssh-family sandbox escape, and the `restart` / `update` / `cloud` / `gateway restart` lifecycle commands — are enforced by dedicated argv predicates. They come in two shapes, and the difference is whether a catalog row exists to gate on:
- **Row-backed, UNION with the regex tier** (`_SELF_PROTECTION_FLOOR_RULE_IDS`: the credential mint, the self-kill, the dev-mode confirm flag, and `sandbox-escape-ssh-self` — an ssh/scp/sftp/rsync TARGET resolving to this same machine re-enters the host outside the sandbox, so the `_is_ssh_to_self` predicate resolves the operand-position target: options, redirects, `user@`/URI/IPv6/numeric-IP spellings, and this host's own names, the last resolved in a background thread so the gate never blocks on DNS). On Linux a non-own IP literal in host position is refused until the netlink `RTM_GETADDR` address table has published (`_NETLINK_ADDRS_PUBLISHED`), because it could be an unlisted secondary of this machine; the gateway warms that table at boot without blocking (`warm_own_host_names`, run through `asyncio.to_thread`). Hosts-file aliases are read into a table (`hosts_file.py`): a file up to 64 KiB is parsed in the same call on a miss, a larger one is parsed by a background warm when the check runs on an event loop, and in the same call (read up to `_HOSTS_FILE_READ_CAP`, exactly as the warm reads it) on a thread with no running loop (`atomic_write.on_event_loop`), such as a stdio MCP server or the cron fire-time vet, where a pending answer would not be retried; a failed read answers fail closed; on Windows a dotless name is refused while the hosts file is over 64 KiB, since its content cannot be verified. The refusal note names three labelled cases — **PENDING** (retry: classification is still running), **Still refused** (an unresolved name, an unreadable address list, or the target really is this machine), and **FORWARDED** port (a forwarded port on a loopback address is denied like the host). Enforced by **both** the row's `pattern` in the regex tier **and** the predicate, and the predicate runs only while the row is in the effective set, so an operator who disabled the row has disabled the floor with it. Neither half is sufficient alone, and the floor is deliberately **additive**, never a replacement. This gate is the **interim tier**, not closure of the escape: it sees only tool-routed command lines, so interpreter/script-file indirection (`bash escape.sh`, `python -c` + subprocess/paramiko) and non-ssh-family clients (`git clone ssh://localhost/…`, autossh) still reach the unsandboxed sshd — the sandbox has no network namespace, and the fix of record is an OS-level network fence there (issue #9806).
- **Row-backed, UNION with the regex tier** (`_SELF_PROTECTION_FLOOR_RULE_IDS`: the credential mint, the self-kill, the dev-mode confirm flag, and `sandbox-escape-ssh-self` — an ssh/scp/sftp/rsync TARGET resolving to this same machine re-enters the host outside the sandbox, so the `_is_ssh_to_self` predicate resolves the operand-position target: options, redirects, `user@`/URI/IPv6/numeric-IP spellings, and this host's own names, the last resolved in a background thread so the gate never blocks on DNS). On Linux a non-own IP literal in host position is refused until the netlink `RTM_GETADDR` address table has published (`_NETLINK_ADDRS_PUBLISHED`), because it could be an unlisted secondary of this machine; the gateway warms that table at boot without blocking (`warm_own_host_names`, run through `asyncio.to_thread`). Hosts-file aliases are read into a table (`hosts_file.py`): a file up to 64 KiB is parsed in the same call on a miss, a larger one is parsed by a background warm when the check runs on an event loop, and in the same call (read up to `_HOSTS_FILE_READ_CAP`, exactly as the warm reads it) on a thread with no running loop (`atomic_write.on_event_loop`), such as a stdio MCP server or the cron fire-time vet, where a pending answer would not be retried; a failed read answers fail closed; the table is keyed on the file's stat plus, for a file up to 64 KiB, a content digest on every platform, since a same-size rewrite inside one coarse ctime tick leaves the stat unchanged (a larger POSIX file keeps a stat-only key, so a same-size rewrite inside the ctime tick of its previous write is still served stale); on Windows a dotless name is refused while the hosts file is over 64 KiB, since its content cannot be verified. The refusal note names three labelled cases — **PENDING** (retry: classification is still running), **Still refused** (an unresolved name, an unreadable address list, or the target really is this machine), and **FORWARDED** port (a forwarded port on a loopback address is denied like the host). Enforced by **both** the row's `pattern` in the regex tier **and** the predicate, and the predicate runs only while the row is in the effective set, so an operator who disabled the row has disabled the floor with it. Neither half is sufficient alone, and the floor is deliberately **additive**, never a replacement. This gate is the **interim tier**, not closure of the escape: it sees only tool-routed command lines, so interpreter/script-file indirection (`bash escape.sh`, `python -c` + subprocess/paramiko) and non-ssh-family clients (`git clone ssh://localhost/…`, autossh) still reach the unsandboxed sshd — the sandbox has no network namespace, and the fix of record is an OS-level network fence there (issue #9806).
- **Floor-only, UNGATED** (`_SELF_PROTECTION_UNGATED_FLOOR_IDS`: `self-protection-restart`, `self-protection-update`, `self-protection-gateway-restart`, `self-protection-cloud`). These four have **no catalog row**: their regex rows fired on the product's name appearing anywhere in a command and were deleted (see "Self-protection global options" above). With no row there is no toggle, so the predicates run unconditionally — like the git-publish anti-obfuscation branches — and a refusal's first line reports the id itself, with the structural note on the second line. The ids are deliberately not catalog ids, and the two sets are pinned disjoint: gating a predicate on a row lookup was how a deleted row became a silently disabled floor (`is_denied` used to `continue` past an id that resolved to no pattern), so a subcommand floor may never rejoin the gated set without its row. `_LEGACY_RULE_ID_BY_PATTERN` carries no alias for those rows for the same reason — a governance pin persisted in either spelling of those rows resolves to nothing, which is the truth: an always-on floor has nothing left to pin. The map aliases only a prior spelling of a row that still exists (the bare-substring spelling of `reverse-shell-nc`, whose row is anchored to the `nc` command token), so a ceiling written against the older catalog keeps pinning that row across the upgrade.
- **Why the floor exists.** These rules must distinguish a dangerous *invocation* from an incidental *mention*, and raw-text matching cannot: the gap between the product name and the dangerous verb has to tolerate ordinary shell noise (a quoted verb `kirocrew "token"`, global flags, a redirection — bash accepts one anywhere in a simple command, so `kirocrew >/tmp/out token` is a mint), but any character class wide enough for that also spans a filesystem path, and a product-named worktree path (`…/kirocrew-wt-x/test_token_auth.py`) is the false positive the rules must not produce. Quoting cuts the other way too: `pkill -f '[;]*kirocrew'` is a working by-name kill whose quoted `;` textual splitting misreads as a command separator. The floor therefore tokenizes with `normalize_shell_command()` (resolving quoting, empty-string concatenation, `$HOME`/tilde) and matches on **argv**: lifecycle rules recognize both the console script and the documented `python -m kiro_crew` entrypoint, begin after the module operand, and compare the first non-flag CLI words exactly; a Python script, another module, or an ordinary command that merely contains `kiro_crew` remains a mention rather than an invocation. For the mint, a token whose whole *program name* is `kiro[-.]?crew` followed by a later token in the same argv that is exactly `token`; for the kill, `pkill`/`killall` with the name in any argument (unbounded — the target is a pattern, not a path), or a bare `kill` whose PID comes from a command-substitution **body** naming it (walked with paren nesting, so `$(pgrep …)`, `$(pidof …)`, a pidfile read and backticks are all covered without allowlisting a resolver binary). The bare-kill bodies are read through TWO windows with complementary blind spots — a token walk (whose depth counter mis-scores a `case` pattern's `)` as a closer, losing a lookup placed after `case … esac`) and a quote-aware raw-text window (which reaches that clause but sees the quotes) — so each raw body is searched raw, with parameter defaults resolved, and per WORD of its `_self_tokens` view (which folds continuations and swallows an untokenizable body instead of raising out of the gate), each word BARE and through `_resolved_word_view` (defaults resolved, empty substitutions collapsed, bracket classes removed — quote removal composes with the remaining transforms, and the bare member keeps a name a pattern-substitution expansion like `${PATH/usr/|'kiro''crew'|zz-}` would destroy); the per-word transform deliberately avoids `_normalize_operand` (the `pkill` leg keeps it only as one additive member and searches each argument through `_resolved_word_view` too, so `'zz|kiro'${x:-crew}` and `'zz|kiro'$()crew` — a pattern bash passes as `zz|kirocrew` — are denied there as well, issue #10284), because an operand view truncates at a de-quoted ERE pattern's own characters (`'zz|kiro'crew`) and would discard the protected alternative. Without the composed per-word leg an adjacent-quote concatenation of the name after `esac` (`kill $(case x in x) :;; esac; pgrep -f 'kiro''crew')`) — or the same splice composed with a `[c]` bracket class, an empty `$()`, or a `${x:-crew}` default — sat in the two windows' intersection and was allowed (issue #10205).
- **Why the regex stays.** A shell's `-c` argument is a *command*, so `bash -c "kirocrew token"` hands the tokenizer one opaque token. The floor closes that class by re-tokenizing literal `sh`/`bash`/`zsh` `-c` payloads and `eval` arguments and checking those argvs too (`_self_token_frames`, depth-capped), but a payload that is not literal (`eval "$CMD"`) has no visible script, and the tokenizer itself can fail (unbalanced quotes). Keeping both `pattern`s in `regex_patterns` means a tokenizer failure or an unseen payload **fails closed** on raw text rather than open. `test_denied_commands_security.py::TestSelfProtectionFloorIsAdditive` pins this: the patterns must stay in the effective set, each pattern must be a true **subset** of its predicate (so the posture-UI text cannot drift from enforcement), and a simulated tokenizer failure must still deny.
Expand Down
2 changes: 1 addition & 1 deletion src/kiro_crew/security/argv_floor.py
Original file line number Diff line number Diff line change
Expand Up @@ -260,9 +260,9 @@
_windows_interface_addresses,
)
from kiro_crew.security.hosts_file import ( # noqa: F401
_ctime_is_creation_time,
_decoded_chunks,
_hosts_content_digest,
_hosts_content_digest_enabled,
_HostsFileTooLarge,
_HostsFileUnreadable,
_parse_hosts_chunks,
Expand Down
54 changes: 27 additions & 27 deletions src/kiro_crew/security/argv_floor_hosts.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@
_windows_interface_addresses,
)
from .hosts_file import (
_ctime_is_creation_time,
_decoded_chunks,
_hosts_content_digest,
_hosts_content_digest_enabled,
_HostsFileTooLarge,
_HostsFileUnreadable,
_parse_hosts_chunks,
Expand Down Expand Up @@ -246,21 +246,20 @@ def _read_hosts_bytes(path: str, limit: int, *, strict: bool) -> bytes:
def _hosts_file_key(path: str) -> "_HostsKey":
"""Cache key for *path*: stat identity, content digest, and the own-address state.

A same-size rewrite that restores mtime must still be a new key. On
POSIX ``st_ctime`` catches it: any write or chmod sets ctime, and no
extra read is done (the digest field is None). On Windows
``st_ctime`` is creation time and catches nothing, so for a file no
larger than one read chunk the key also carries a blake2b digest of
that file, from one read bounded by the chunk size; a read that fails
or finds more than a chunk raises ``_HostsFileUnreadable``. A Windows
file over one chunk keeps digest None and is never cached or served:
its content cannot be verified without a gate read past the chunk, so
A same-size rewrite that restores mtime must still be a new key, and
``st_ctime`` cannot promise it: Windows stamps creation time, Linux a
coarse clock that two writes inside one tick share. So a file no
larger than one read chunk also keys on a blake2b digest from one read
bounded by the chunk; a read that fails or finds more raises
``_HostsFileUnreadable``. A larger file keeps digest None: on POSIX a
same-size rewrite inside its previous write's ctime tick keeps the key
(the one window left); on Windows it is never cached or served, so
``_hosts_file_verdict`` answers every dotless name pending. The last
two fields are the publication flag and the own set.
"""
stat = os.stat(path)
digest: "bytes | None" = None
if _hosts_content_digest_enabled() and stat.st_size <= _HOSTS_FILE_READ_CHUNK:
if stat.st_size <= _HOSTS_FILE_READ_CHUNK:
try:
data = _read_hosts_bytes(path, _HOSTS_FILE_READ_CHUNK, strict=True)
except (OSError, _HostsFileTooLarge) as exc:
Expand Down Expand Up @@ -343,10 +342,10 @@ def _parse_and_cache_for_key(
an everyday ``ssh user@dev-dsk`` from being refused by a worker thread
this same call started.

When *key* carries a digest (Windows), the bytes are read once, hashed,
and parsed from that same buffer, so the digest names exactly the
content the table came from; a digest that differs from *key*'s is
``"changed"``.
When *key* carries a digest (a file no larger than one chunk), the
bytes are read once, hashed, and parsed from that same buffer, so the
digest names exactly the content the table came from; a digest that
differs from *key*'s is ``"changed"``.
"""
try:
if key[3] is not None:
Expand Down Expand Up @@ -377,7 +376,7 @@ def _parse_and_cache_for_key(

def _unverifiable_hosts_file(key: "_HostsKey") -> bool:
"""True for a Windows file over one chunk: no digest, so its content cannot be checked."""
return key[3] is None and _hosts_content_digest_enabled()
return key[3] is None and _ctime_is_creation_time()


def _warm_hosts_file_cache() -> None:
Expand All @@ -386,9 +385,9 @@ def _warm_hosts_file_cache() -> None:
One parse per path per call (``_parse_and_cache_for_key``). A changed
key caches nothing; the gate keeps answering pending for a file over the
in-call cap, and the next pass, or the next gate miss, retries. An
up-to-date table costs a stat and an own-set read (plus, on Windows, a
bounded read and hash). A Windows file over one chunk is skipped: the
gate never serves its table. Best-effort.
up-to-date table costs a stat and an own-set read (plus, for a file no
larger than one chunk, a bounded read and hash). A Windows file over
one chunk is skipped: the gate never serves its table. Best-effort.
"""
for path in _hosts_file_paths():
try:
Expand Down Expand Up @@ -436,8 +435,8 @@ def _hosts_file_verdict(host: str) -> "bool | None":

This is the round-18 attack vector itself — a hosts-file alias for a
loopback/local address — answered from the table cached for the file's
current key. Per call the gate stats each file (on Windows, also one
bounded read and hash of a file no larger than one chunk; a miss adds
current key. Per call the gate stats each file (and for a file no
larger than one chunk, one bounded read and hash; a miss adds
two more, for the parse and its key re-check) and reads the
own-address set (twice when a remote entry is served: once for the
key, once to confirm it at return; each read is one lock round-trip
Expand All @@ -459,12 +458,13 @@ def _hosts_file_verdict(host: str) -> "bool | None":
parses a larger file in the call exactly as the warm would, read up to
``_HOSTS_FILE_READ_CAP`` and truncated past it.

A same-size rewrite that restores mtime is a new key: on POSIX through
``st_ctime``, with no extra read; on Windows, where ``st_ctime`` is
creation time, through a content digest taken on every call from one
read bounded by the chunk size, for a file no larger than one chunk.
A Windows file over one chunk cannot be verified without reading past
that bound, so every dotless name is pending there (True), with no
A same-size rewrite that restores mtime is a new key: for a file no
larger than one chunk through a content digest taken on every call
from one read bounded by the chunk size, on every platform, since a
coarse ctime tick can hide a rewrite; for a larger POSIX file through
``st_ctime`` (see ``_hosts_file_key``). A Windows file over one chunk
cannot be verified without reading past that bound, so every dotless
name is pending there (True), with no
stale window; the refusal note tells the agent to use the full
hostname or an IP address. An in-call parse whose FILE changed
while it ran is also pending; one whose own-address state moved (the
Expand Down
12 changes: 6 additions & 6 deletions src/kiro_crew/security/hosts_file.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
"""Pure helpers for the ssh self-target floor's hosts-file table.

The line parser that turns hosts-file text, fed one chunk at a time, into
``{name -> maps-to-local}``, the content digest a Windows cache key carries,
and the two exceptions the reads raise. ``argv_floor_hosts`` owns every read,
the cache and the gate; this parsing plumbing sits in its own module, which
keeps it out of that module's per-module liveness cap.
``{name -> maps-to-local}``, the content digest a small file's cache key
carries, and the two exceptions the reads raise. ``argv_floor_hosts`` owns
every read, the cache and the gate; this parsing plumbing sits in its own
module, which keeps it out of that module's per-module liveness cap.

Layer. This module imports nothing from the package: ``argv_floor_hosts`` imports
these names and re-binds them in its own namespace, which a patch through
Expand All @@ -28,8 +28,8 @@ class _HostsFileUnreadable(Exception):
"""The key's digest read failed; not an OSError, so the gate cannot take it as absent."""


def _hosts_content_digest_enabled() -> bool:
"""True where ``st_ctime`` is creation time (Windows); a seam for tests."""
def _ctime_is_creation_time() -> bool:
"""True where ``st_ctime`` is creation time, not change time (Windows); a seam for tests."""
return os.name == "nt"


Expand Down
Loading
Loading