Skip to content
Closed
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
4 changes: 2 additions & 2 deletions docs/system-specs/common/testing-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2826,7 +2826,7 @@ negative control before it was believed, and that is what caught them.
re-resolving the name is. Both halves are worth having: the test stops accusing observers,
and a well-behaved observer stops being visible to tests that patch a stdlib symbol.
- **A datagram `connect()` sends no packet, so it is a routing-table query and not a network
connection.** `src/kiro_crew/security/argv_floor.py`'s own-address enumeration connects a
connection.** `src/kiro_crew/security/ssh_self_floor.py`'s own-address enumeration connects a
`SOCK_DGRAM` socket to `198.51.100.1:53` and `2001:db8::1:53` — TEST-NET-2 and
documentation addresses, contacted by nobody — to learn the primary outbound address per
family, and says so in a comment three lines above. The `socket.connect` audit hook never
Expand Down Expand Up @@ -3191,7 +3191,7 @@ went red, or the fix lands in the wrong file and the leak stays.
`/proc/sys/kernel/pid_max` so the kernel's range check, not convention, is what makes
the number unreachable.
- **`network`: rank the address before you file the finding.** The UDP `connect` to
`198.51.100.1:53` / `2001:db8::1:53` in `argv_floor._own_interface_addresses` is the
`198.51.100.1:53` / `2001:db8::1:53` in `ssh_self_floor._own_interface_addresses` is the
packet-less local-IP trick over TEST-NET, a hardening item: `test_security.py` now routes
it through an inert `_InertDatagramSocket` at the module's `socket` binding (datagram
construction only; every other socket kind passes through) that records the peer and
Expand Down
9 changes: 9 additions & 0 deletions docs/system-specs/modules/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,15 @@ Two mechanisms make "the split changed nothing for a caller" a tested claim rath
Governance-home path writes are intentionally outside this module and are
enforced by the OS sandbox. Structure is the point: the product name in a path,
search pattern, or commit message is not itself a verdict.
- `ssh_self_floor.py` — the sandbox-escape floor (`_is_ssh_to_self`): an
ssh/scp/sftp/rsync target that resolves to this same host, together with the
own-host name and address cache, the hosts-file verdict cache, and the DNS
verdict layer behind it. It was split out of `argv_floor.py` as one cohesive
cluster so the argv floor keeps headroom under the per-module liveness cap
(#10283). It reads two argv-floor helpers (`_self_token_frames`,
`_static_substitution_output`) as attributes of that module, and `argv_floor`
never imports it, so the edge runs one way. `hosts_file.py` and
`host_addresses.py` sit below it and import nothing from the package.
- `readonly_bash.py` — the read-only bash classifier: `is_read_only_bash` / `unsafe_bash_reason`, the last gate before a shell command auto-approves with no human prompt under `--approval reads` / trust-reads and in the tool gate's `read-only` tier (`hook_runtime/gate_tiers.py`), together with every table that verdict rests on -- the prefix allowlist, the per-verb write, exec and indirection flag denylists, the git ref and remote subcommand rules, the positive option accept-lists for the four tools whose surface is small enough to enumerate (`sort`, `date`, `file`, `hostname`), and the shell-expansion readers that decide whether a token's real spelling is knowable before it runs. Deny-by-default: a command has to be RECOGNISED as read-only, so a spelling nobody thought of prompts rather than passes, and every table entry carries the measurement that put it there. It imports nothing from the package and nothing from the dashboard, which is what lets `hooks.py` import it at module top; its two consumers are `dashboard/chat_runner.py` (the approval flow, where the reason text becomes the refusal card) and the gate's `read-only` tier (the auto-approve branch, reading it through `hooks.py`). It is NOT a facade submodule and is reached by its own path, for two reasons. It is not a piece of the split: the classifier came here from `dashboard/state.py`, where no caller or patch site ever reached it as `kiro_crew.security.<name>`, so the facade has nothing to preserve for it and adding its private tables to the frozen manifest would widen the facade's API for no caller. And it answers a different question from the tiers the facade fronts: those decide whether a command is DENIED, and `hooks.on_tool_call` runs every one of them before it asks this module whether the survivor is read-only enough to skip the prompt -- a verdict layer above the deny tiers, not one of them, so it does not belong in a dependency order whose top is the argv floor. Pinned by `test_trust_reads.py`.

## Threat Model
Expand Down
2 changes: 1 addition & 1 deletion src/kiro_crew/dashboard/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,7 @@
take_dropped_grant,
)
from kiro_crew.security import redact_credentials, redact_exfiltration_urls # noqa: F401
from kiro_crew.security.argv_floor import warm_own_host_names # noqa: F401
from kiro_crew.security.ssh_self_floor import warm_own_host_names # noqa: F401
from kiro_crew.sel import sel, sel_is_warm, warm_sel_singleton # noqa: F401
from kiro_crew.skill_usage import register_skill_read_observer
from kiro_crew.skills import ( # noqa: F401
Expand Down
103 changes: 54 additions & 49 deletions src/kiro_crew/security/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
perm_verb_mention,
redaction,
shell_normalizer,
ssh_self_floor,
vocabulary,
)

Expand Down Expand Up @@ -1192,6 +1193,7 @@ def is_denied(
# cost flat -- one import-system lookup per owner, not one per name read.
_rules = _submodule("denied_rules")
_argv = _submodule("argv_floor")
_ssh = _submodule("ssh_self_floor")
_shell = _submodule("shell_normalizer")
_diag = _submodule("diagnostics")

Expand Down Expand Up @@ -1396,7 +1398,7 @@ def _reason(
("credential-exfil-kirocrew-token", _argv._is_credential_mint),
("self-protection-kill", _argv._is_self_kill),
("self-protection-dev-mode-out-of-root-confirm", _argv._is_dev_mode_out_of_root_confirm),
("sandbox-escape-ssh-self", _argv._is_ssh_to_self),
("sandbox-escape-ssh-self", _ssh._is_ssh_to_self),
):
pattern = _rules._SELF_PROTECTION_FLOOR_BY_ID.get(rule_id)
if pattern is None or pattern not in floor_enabled:
Expand Down Expand Up @@ -2581,15 +2583,11 @@ def redact_and_truncate(text: str, max_chars: int = 4000) -> str:
"_AMBIGUOUS_REFS": "argv_floor",
"_AMBIGUOUS_REFSPEC_RE": "argv_floor",
"_DEV_MODE_CONFIRM_FLAG": "argv_floor",
"_EXPANSION_DEFAULT_RE": "argv_floor",
"_GIT_ARG_FLAGS": "argv_floor",
"_GIT_PUBLISH_DENY_LABEL": "argv_floor",
"_GIT_PUBLISH_GLUE_RE": "argv_floor",
"_GIT_PUBLISH_RE": "argv_floor",
"_GIT_PUBLISH_SUBST_PROGRAM_RE": "argv_floor",
"_HOSTNAME_SUBSTITUTION_HINTS": "argv_floor",
"_HOSTNAME_VARIABLE_FORMS": "argv_floor",
"_LOOPBACK_HOST_NAMES": "argv_floor",
"_PROCESS_SUBSTITUTION_SAFE_CHARS": "argv_floor",
"_PROTECTED_BRANCHES": "argv_floor",
"_PUSH_ALL_BRANCHES_OPTS": "argv_floor",
Expand All @@ -2598,25 +2596,18 @@ def redact_and_truncate(text: str, max_chars: int = 4000) -> str:
"_PUSH_REPO_OPTS": "argv_floor",
"_PUSH_VALUE_OPTS": "argv_floor",
"_PUSH_VALUE_SHORTS": "argv_floor",
"_QUOTED_SEP_SENTINELS": "argv_floor",
"_RAW_ASSIGNMENT_RE": "argv_floor",
"_RSYNC_RSH_ASSIGN_RE": "argv_floor",
"_SELF_CLOUD_DESTRUCTIVE_VERBS": "argv_floor",
"_SELF_FLOOR_MACHINERY_RE": "argv_floor",
"_SELF_FLOOR_NAME_HINT_RE": "argv_floor",
"_SELF_FLOOR_QUOTE_JUNK_RE": "argv_floor",
"_SELF_IMPORT_RE": "argv_floor",
"_SELF_MODULE_SPELLINGS": "argv_floor",
"_SHELL_RESERVED_WORDS": "argv_floor",
"_SSH_COMMAND_OPTION_KEYS": "argv_floor",
"_SSH_FAMILY_VERBS": "argv_floor",
"_SSH_FORWARD_OPT_LETTERS": "argv_floor",
"_SSH_ROUTING_OPTION_KEYS": "argv_floor",
"_SelfModuleScan": "argv_floor",
"_bare_kill_raw_bodies": "argv_floor",
"_git_publish_floor_tags": "argv_floor",
"_git_push_args": "argv_floor",
"_host_is_self": "argv_floor",
"_is_credential_mint": "argv_floor",
"_is_dev_mode_out_of_root_confirm": "argv_floor",
"_is_git_publish": "argv_floor",
Expand All @@ -2631,33 +2622,45 @@ def redact_and_truncate(text: str, max_chars: int = 4000) -> str:
"_is_self_module_invocation": "argv_floor",
"_is_self_restart": "argv_floor",
"_is_self_update": "argv_floor",
"_is_ssh_to_self": "argv_floor",
"_kill_prefix_keeps_anchor": "argv_floor",
"_mask_quoted_separators": "argv_floor",
"_matches_self_subcommand": "argv_floor",
"_normalize_ref": "argv_floor",
"_operand_targets_self": "argv_floor",
"_operands_lead_with": "argv_floor",
"_own_host_names": "argv_floor",
"_own_host_seed": "argv_floor",
"_own_interface_addresses": "argv_floor",
"_process_substitution_word_is_opaque": "argv_floor",
"_proxyjump_value_targets_self": "argv_floor",
"_push_segment_targets_protected": "argv_floor",
"_resolve_own_host_names": "argv_floor",
"_resolve_own_host_names_into_cache": "argv_floor",
"_routing_option_key_value_targets_self": "argv_floor",
"_routing_option_value_targets_self": "argv_floor",
"_self_cli_operands": "argv_floor",
"_self_floor_can_fire": "argv_floor",
"_self_module_flag_scan": "argv_floor",
"_self_module_name_index": "argv_floor",
"_self_program_index": "argv_floor",
"_self_token_frames": "argv_floor",
"_shell_payload_sources": "argv_floor",
"_ssh_family_verb": "argv_floor",
"_static_substitution_output": "argv_floor",
"_unmask_separators": "argv_floor",
# ssh_self_floor
"_EXPANSION_DEFAULT_RE": "ssh_self_floor",
"_HOSTNAME_SUBSTITUTION_HINTS": "ssh_self_floor",
"_HOSTNAME_VARIABLE_FORMS": "ssh_self_floor",
"_LOOPBACK_HOST_NAMES": "ssh_self_floor",
"_QUOTED_SEP_SENTINELS": "ssh_self_floor",
"_RSYNC_RSH_ASSIGN_RE": "ssh_self_floor",
"_SSH_COMMAND_OPTION_KEYS": "ssh_self_floor",
"_SSH_FAMILY_VERBS": "ssh_self_floor",
"_SSH_FORWARD_OPT_LETTERS": "ssh_self_floor",
"_SSH_ROUTING_OPTION_KEYS": "ssh_self_floor",
"_host_is_self": "ssh_self_floor",
"_is_ssh_to_self": "ssh_self_floor",
"_mask_quoted_separators": "ssh_self_floor",
"_operand_targets_self": "ssh_self_floor",
"_own_host_names": "ssh_self_floor",
"_own_host_seed": "ssh_self_floor",
"_own_interface_addresses": "ssh_self_floor",
"_proxyjump_value_targets_self": "ssh_self_floor",
"_resolve_own_host_names": "ssh_self_floor",
"_resolve_own_host_names_into_cache": "ssh_self_floor",
"_routing_option_key_value_targets_self": "ssh_self_floor",
"_routing_option_value_targets_self": "ssh_self_floor",
"_ssh_family_verb": "ssh_self_floor",
"_unmask_separators": "ssh_self_floor",
# denied_rules
"BUILTIN_DENIED_RULES": "denied_rules",
"BUILTIN_DENY_PATTERNS": "denied_rules",
Expand Down Expand Up @@ -3165,15 +3168,11 @@ def __delattr__(self, name: str) -> None:
_AMBIGUOUS_REFS,
_AMBIGUOUS_REFSPEC_RE,
_DEV_MODE_CONFIRM_FLAG,
_EXPANSION_DEFAULT_RE,
_GIT_ARG_FLAGS,
_GIT_PUBLISH_DENY_LABEL,
_GIT_PUBLISH_GLUE_RE,
_GIT_PUBLISH_RE,
_GIT_PUBLISH_SUBST_PROGRAM_RE,
_HOSTNAME_SUBSTITUTION_HINTS,
_HOSTNAME_VARIABLE_FORMS,
_LOOPBACK_HOST_NAMES,
_PROCESS_SUBSTITUTION_SAFE_CHARS,
_PROTECTED_BRANCHES,
_PUSH_ALL_BRANCHES_OPTS,
Expand All @@ -3182,24 +3181,17 @@ def __delattr__(self, name: str) -> None:
_PUSH_REPO_OPTS,
_PUSH_VALUE_OPTS,
_PUSH_VALUE_SHORTS,
_QUOTED_SEP_SENTINELS,
_RAW_ASSIGNMENT_RE,
_RSYNC_RSH_ASSIGN_RE,
_SELF_CLOUD_DESTRUCTIVE_VERBS,
_SELF_FLOOR_MACHINERY_RE,
_SELF_FLOOR_NAME_HINT_RE,
_SELF_FLOOR_QUOTE_JUNK_RE,
_SELF_IMPORT_RE,
_SELF_MODULE_SPELLINGS,
_SHELL_RESERVED_WORDS,
_SSH_COMMAND_OPTION_KEYS,
_SSH_FAMILY_VERBS,
_SSH_FORWARD_OPT_LETTERS,
_SSH_ROUTING_OPTION_KEYS,
_bare_kill_raw_bodies,
_git_publish_floor_tags,
_git_push_args,
_host_is_self,
_is_credential_mint,
_is_dev_mode_out_of_root_confirm,
_is_git_publish,
Expand All @@ -3214,23 +3206,12 @@ def __delattr__(self, name: str) -> None:
_is_self_module_invocation,
_is_self_restart,
_is_self_update,
_is_ssh_to_self,
_kill_prefix_keeps_anchor,
_mask_quoted_separators,
_matches_self_subcommand,
_normalize_ref,
_operand_targets_self,
_operands_lead_with,
_own_host_names,
_own_host_seed,
_own_interface_addresses,
_process_substitution_word_is_opaque,
_proxyjump_value_targets_self,
_push_segment_targets_protected,
_resolve_own_host_names,
_resolve_own_host_names_into_cache,
_routing_option_key_value_targets_self,
_routing_option_value_targets_self,
_self_cli_operands,
_self_floor_can_fire,
_self_module_flag_scan,
Expand All @@ -3239,9 +3220,7 @@ def __delattr__(self, name: str) -> None:
_self_token_frames,
_SelfModuleScan,
_shell_payload_sources,
_ssh_family_verb,
_static_substitution_output,
_unmask_separators,
)
from kiro_crew.security.denied_rules import ( # noqa: F401
_AWS_SECRET_VAR_NAMES,
Expand Down Expand Up @@ -3645,6 +3624,32 @@ def __delattr__(self, name: str) -> None:
_xargs_reconstructed_command,
normalize_shell_command,
)
from kiro_crew.security.ssh_self_floor import ( # noqa: F401
_EXPANSION_DEFAULT_RE,
_HOSTNAME_SUBSTITUTION_HINTS,
_HOSTNAME_VARIABLE_FORMS,
_LOOPBACK_HOST_NAMES,
_QUOTED_SEP_SENTINELS,
_RSYNC_RSH_ASSIGN_RE,
_SSH_COMMAND_OPTION_KEYS,
_SSH_FAMILY_VERBS,
_SSH_FORWARD_OPT_LETTERS,
_SSH_ROUTING_OPTION_KEYS,
_host_is_self,
_is_ssh_to_self,
_mask_quoted_separators,
_operand_targets_self,
_own_host_names,
_own_host_seed,
_own_interface_addresses,
_proxyjump_value_targets_self,
_resolve_own_host_names,
_resolve_own_host_names_into_cache,
_routing_option_key_value_targets_self,
_routing_option_value_targets_self,
_ssh_family_verb,
_unmask_separators,
)
from kiro_crew.security.vocabulary import ( # noqa: F401
_KILL_BY_NAME_PROGRAMS,
_SELF_NAME_RE,
Expand Down
Loading
Loading