From 2072a31878a2e9cc81df161835d43f3c4aa98c3e Mon Sep 17 00:00:00 2001 From: rubencu <4742789+rubencu@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:36:10 +0000 Subject: [PATCH] security: deny `kirocrew skills authority-retire` from an agent shell The auto-skill publication authority (#18595) gains an operator-only recovery, `kirocrew skills authority-retire`, that moves an idle authority and its provenance record aside. It must run from the operator's own terminal, so this adds the ungated `self-protection-skills-authority-retire` argv floor beside the existing self-protection subcommand floors: one structural matcher, its rule id and reason, its `deny_gate` registration and facade export, and its name in the ungated-floor comment. No catalog row, no opt-out; no existing pass widened and no threshold moved. Like its sibling floors, it refuses the command's `--help` form too: a word that reads as `--help` can reach the shell as nothing or as a plain argument, and the retire then runs. This is the whole sensitive-path footprint of the auto-skill authority, split out so a maintainer can land it from a repository branch; #18595 touches no sensitive path and follows as an ordinary fork PR once this is on main. Tests: the floor's template, list, structural and help-form assertions in test_denied_commands_security.py; the security package line budget raised from 32,188 to the measured 32,206. --- docs/system-specs/modules/security.md | 4 +-- src/kiro_crew/security/__init__.py | 2 ++ src/kiro_crew/security/_exports.py | 1 + src/kiro_crew/security/argv_floor.py | 1 + src/kiro_crew/security/argv_floor_self.py | 5 ++++ src/kiro_crew/security/denied_rules_tables.py | 10 ++++++- src/kiro_crew/security/deny_gate.py | 1 + test/test_denied_commands_security.py | 26 ++++++++++++++++++- test/test_security_gate_liveness.py | 9 ++++++- 9 files changed, 54 insertions(+), 5 deletions(-) diff --git a/docs/system-specs/modules/security.md b/docs/system-specs/modules/security.md index c9e7ef83b30..ef047bd2ee6 100644 --- a/docs/system-specs/modules/security.md +++ b/docs/system-specs/modules/security.md @@ -1719,9 +1719,9 @@ continue to apply. - `_PUSH_ALL_BRANCHES_OPTS` covers `--mirror`, `--all`, and Git's `--branches` alias are denied **outright** (they push every local branch, so a per-branch target check cannot vouch for them), kept in lockstep with the `--(mirror|all)` regex in `config/defaults.json`. - `_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: + - **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, the `file-delivery approve` consent step-up, and the `skills authority-retire` recovery — 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; 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. + - **Floor-only, UNGATED** (`_SELF_PROTECTION_UNGATED_FLOOR_IDS`: `self-protection-restart`, `self-protection-update`, `self-protection-file-delivery`, `self-protection-gateway-restart`, `self-protection-cloud`, `self-protection-skills-authority-retire`). These six have **no catalog row**: the four lifecycle floors' regex rows fired on the product's name appearing anywhere in a command and were deleted (see "Self-protection global options" above), the `file-delivery approve` floor never had one: it denies completing a flagged-file delivery consent step-up (the read-only `file-delivery` and `file-delivery --help` forms stay allowed), and the `skills authority-retire` floor never had one either: it denies the operator-terminal recovery that moves the auto-skill publication authority aside (`kirocrew skills authority-retire`), which an agent shell must not run because the authority is what keeps an agent from loosening its own skill governance. Like the other subcommand floors, it refuses that command's `--help` form too; the refusal note sends the operator to their own terminal, and `kirocrew skills --help` stays allowed. 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_rows.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. - **Inline interpreter programs are judged on what they NAME, not on whether they mention us.** `python -c` and the stdin forms (`python - <<'PY'`, `echo … | python -`, `python < file`) run arbitrary Python, so the `token` argv word cannot be the mint gate there (the payload can build the verb). The gate is `_inline_payload_reaches_cli`: the payload is a mint when it names the **mint surface** (`_MINT_SURFACE_RE`) — the CLI dispatch (`kiro_crew.cli`, `kiro_crew.__main__`, the console-script entry `kiro_crew._bootstrap`, `from kiro_crew import cli`), the module implementing the `token` subcommand (`kiro_crew.cli_server`, with its `cli_server_` owners, which hold that command and the stop, restart and update commands that read the local secret and print fresh token URLs), a product module whose path names a token producer (`kiro_crew.dashboard.token_auth`, `kiro_crew.instances.token_mint`, `kiro_crew.dashboard.token_secret`), as an import or as a file path — `secret` is not a path word, since `kiro_crew/dashboard/handlers/secrets.py` is an ordinary module a patch script names — a product import statement together with a **credential word**, `token` or `secret`, anywhere in the payload (`_PRODUCT_IMPORT_RE` + `_MINT_VERB_RE`: `from kiro_crew.slack.gateway import generate_token`, `import kiro_crew.slack.gateway as g; g.generate_token()`, and the second credential — `from kiro_crew.config.loader import read_local_secret` reads the internal secret `/api/token/local` accepts through product code, past the sensitive-path floor that fences `.local_secret` itself; every reader of it carries `secret`, as every producer carries `token`, and `test_the_credential_word_covers_every_local_secret_reader_in_the_tree` derives the reader set from every function that names the file), or the bare package name handed to a dynamic module runner (`_DYNAMIC_RUNNER_NAMES`: `runpy.run_module('kiro_crew', run_name='__main__')` is `python -m kiro_crew` spelled as a call, and `importlib.import_module('kiro_crew')` / `__import__('kiro_crew')` reach the same dispatch by attribute; the runner handed any OTHER name, `__import__('os').environ` next to a product import, is generic code), or the installed console script named as a program next to a code loader (`_INLINE_CODE_LOADER_NAMES` + `_CONSOLE_SCRIPT_LITERAL_RE`: `exec(open(shutil.which('kirocrew')).read())`, `exec(open('/venv/bin/kirocrew').read())` or `runpy.run_path(shutil.which('kirocrew'))` runs the entry point's `_bootstrap.main()` in-process against a `sys.argv` the payload chose; the loader on any other file, `exec(open('patch.py').read())`, is a patch-script idiom, and the program name in a `subprocess` argv is the interpreter-argv companion rule's sink). `test_the_mint_surface_covers_every_token_producer_in_the_tree` derives the producer set from the tree (the function that opens `token_signing.key`, then two import hops) and asserts each is a reach. Before matching, `_fold_inline_literals` joins `'a' + 'b'` literal runs and `_decoded_b64_literals` decodes every base64-shaped literal of the folded command **as submitted** (`is_denied` hands the mint predicate `raw_text=` for this: base64 does not survive the lower-casing the rest of the floor reads), so `__import__('kiro_crew.c'+'li')` and `os.system(b64decode('a2lyb2NyZXcgdG9rZW4='))` (`kirocrew token`) are read as what they hide. A payload that merely mentions the package (a `Path(...)` under `src/kiro_crew/` in a patch script), imports an unrelated product module (`from kiro_crew.acp import x`), or uses `getattr`/`eval`/`importlib` with no product name in reach is **not** a mint. The earlier reading — any `kiro_crew` mention, or any dynamic-exec primitive at all, denied the whole inline program as opaque — produced 143 of the audit log's 179 bash-gate denials over eight days with zero mints among them; the un-disableable guarantee for the credential was always the sensitive-path floor over the signing key, which this heuristic sits in front of. Pinned by `test_argv_floor_inline_and_brace_scope.py::TestInlinePayloadNamesTheMintSurface`. diff --git a/src/kiro_crew/security/__init__.py b/src/kiro_crew/security/__init__.py index 6fba0b439f5..c773ee87ba2 100644 --- a/src/kiro_crew/security/__init__.py +++ b/src/kiro_crew/security/__init__.py @@ -169,6 +169,7 @@ "_is_self_module_flag": "argv_floor", "_is_self_module_invocation": "argv_floor", "_is_self_restart": "argv_floor", + "_is_self_skills_authority_retire": "argv_floor", "_is_self_update": "argv_floor", "_is_ssh_to_self": "argv_floor", "_kill_prefix_keeps_anchor": "argv_floor", @@ -830,6 +831,7 @@ def __delattr__(self, name: str) -> None: _is_self_module_flag, _is_self_module_invocation, _is_self_restart, + _is_self_skills_authority_retire, _is_self_update, _is_ssh_to_self, _kill_prefix_keeps_anchor, diff --git a/src/kiro_crew/security/_exports.py b/src/kiro_crew/security/_exports.py index ff4165109f7..7467ee621bd 100644 --- a/src/kiro_crew/security/_exports.py +++ b/src/kiro_crew/security/_exports.py @@ -381,6 +381,7 @@ "_is_self_module_invocation", "_is_self_program", "_is_self_restart", + "_is_self_skills_authority_retire", "_is_self_update", "_is_shell_command_flag", "_is_shell_variable_reference", diff --git a/src/kiro_crew/security/argv_floor.py b/src/kiro_crew/security/argv_floor.py index ab7a22d5720..89e20c364cd 100644 --- a/src/kiro_crew/security/argv_floor.py +++ b/src/kiro_crew/security/argv_floor.py @@ -190,6 +190,7 @@ _is_self_module_flag, _is_self_module_invocation, _is_self_restart, + _is_self_skills_authority_retire, _is_self_update, _kill_prefix_keeps_anchor, _matches_self_subcommand, diff --git a/src/kiro_crew/security/argv_floor_self.py b/src/kiro_crew/security/argv_floor_self.py index 8e80ecc6d72..07609861c7c 100644 --- a/src/kiro_crew/security/argv_floor_self.py +++ b/src/kiro_crew/security/argv_floor_self.py @@ -1069,6 +1069,11 @@ def _is_self_cloud_destructive(text_lower: str) -> bool: return _matches_self_subcommand(text_lower, ("cloud", _SELF_CLOUD_DESTRUCTIVE_VERBS)) +def _is_self_skills_authority_retire(text_lower: str) -> bool: + """``kirocrew skills authority-retire`` behind interposed shell dressing.""" + return _matches_self_subcommand(text_lower, ("skills", "authority-retire")) + + _DEV_MODE_CONFIRM_FLAG = "--confirm-out-of-install-root" diff --git a/src/kiro_crew/security/denied_rules_tables.py b/src/kiro_crew/security/denied_rules_tables.py index 1861ea70678..431d4de37d8 100644 --- a/src/kiro_crew/security/denied_rules_tables.py +++ b/src/kiro_crew/security/denied_rules_tables.py @@ -158,7 +158,8 @@ def floor_enforced_builtin_command_ids() -> frozenset[str]: _SELF_PROTECTION_FLOOR_PATTERNS: frozenset[str] = frozenset(_SELF_PROTECTION_FLOOR_BY_ID.values()) # The self-management SUBCOMMAND floors named in this set -- restart, update, file-delivery -# approve, gateway restart, cloud -- have NO catalog row and NO opt-out. A regex +# approve, gateway restart, cloud , skills authority-retire -- have NO catalog row +# and NO opt-out. A regex # row (``.*kiro.?crew ... restart.*`` and siblings) opening with an unbounded any-run would # match the product name in a worktree path plus the verb word later, denying # ``ls ~/kirocrew-wt/restart.log``, and adds nothing to the structural predicate, which requires @@ -176,6 +177,7 @@ def floor_enforced_builtin_command_ids() -> frozenset[str]: "self-protection-file-delivery", "self-protection-gateway-restart", "self-protection-cloud", + "self-protection-skills-authority-retire", } ) @@ -241,6 +243,12 @@ def floor_enforced_builtin_command_ids() -> frozenset[str]: "program and its leading subcommand is a destructive cloud lifecycle operation. " "This floor has no catalog row and no opt-out." ), + "self-protection-skills-authority-retire": ( + "Matched structurally on the command's argv: the product CLI is the argv's own " + "program and its leading subcommands retire the auto-skill authority. This " + "stopped-installation recovery must run from the operator's own terminal, so " + "this floor has no catalog row and no opt-out." + ), "self-protection-dev-mode-out-of-root-confirm": ( "Matched structurally on the command's argv, not by the pattern text above: " "shell de-escaping resolves an argument to the operator's " diff --git a/src/kiro_crew/security/deny_gate.py b/src/kiro_crew/security/deny_gate.py index dd0eb0e499b..533bd7d98e1 100644 --- a/src/kiro_crew/security/deny_gate.py +++ b/src/kiro_crew/security/deny_gate.py @@ -746,6 +746,7 @@ def _reason( ("self-protection-file-delivery", _argv._is_self_file_delivery), ("self-protection-gateway-restart", _argv._is_self_gateway_restart), ("self-protection-cloud", _argv._is_self_cloud_destructive), + ("self-protection-skills-authority-retire", _argv._is_self_skills_authority_retire), ): if predicate(lower): _emit_deny_event(tool_name, rule_id, lower) diff --git a/test/test_denied_commands_security.py b/test/test_denied_commands_security.py index b7430e065ca..b2adee12832 100644 --- a/test/test_denied_commands_security.py +++ b/test/test_denied_commands_security.py @@ -262,7 +262,7 @@ class TestSelfProtectionFlagInterposition: # verb and the self-target cannot separate anchor from token. "sandbox-escape-ssh-self": "ssh {flags} localhost", } - # FLOOR-ONLY id -> command template. These four have NO catalog row: their + # FLOOR-ONLY id -> command template. These have NO catalog row: their # product-name-anywhere regex rows were deleted and the argv floor # (``_matches_self_subcommand``) is the whole of their enforcement, ungated. _UNGATED_TEMPLATES = { @@ -271,6 +271,7 @@ class TestSelfProtectionFlagInterposition: "self-protection-file-delivery": "kirocrew {flags} file-delivery approve", "self-protection-gateway-restart": "kirocrew {flags} gateway restart", "self-protection-cloud": "kirocrew {flags} cloud destroy", + "self-protection-skills-authority-retire": "kirocrew {flags} skills authority-retire", } _FLAGS = ("-v", "-vv", "--verbose", "--no-jail", "-v --no-jail") @@ -420,6 +421,7 @@ def test_legacy_alias_spellings_stay_out_of_the_enforced_catalog(self): "self-protection-file-delivery": ["file-delivery", "approve"], "self-protection-gateway-restart": ["gateway", "restart"], "self-protection-cloud": ["cloud", "destroy"], + "self-protection-skills-authority-retire": ["skills", "authority-retire"], } @staticmethod @@ -613,10 +615,32 @@ def test_self_protection_floor_covers_every_subcommand_rule(self): assert security._is_self_update("kirocrew \\update") assert security._is_self_gateway_restart("kirocrew -\\v gateway restart") assert security._is_self_cloud_destructive("kirocrew -\\v cloud destroy") + assert security._is_self_skills_authority_retire("kirocrew -\\v skills authority-retire") assert security._is_dev_mode_out_of_root_confirm( "kirocrew app dev x --confirm-out-of-install-'root'" ) + def test_skills_authority_retire_refuses_its_help_form_like_its_sibling_floors(self): + """No help form is exempt, as for ``restart``, ``gateway restart`` and ``cloud``: + a word that reads as ``--help`` can still reach the shell as nothing, or as a + plain argument, and the retire runs. The read-only ``skills`` forms stay allowed.""" + for cmd in ( + "kirocrew skills authority-retire --help", + "kirocrew skills authority-retire -h", + "python -m kiro_crew skills authority-retire --help", + "kirocrew skills authority-retire ${nope:+--help}", + "kirocrew skills authority-retire `--help`", + "kirocrew skills authority-retire my-skill # --help", + "kirocrew skills authority-retire my-skill -- --help", + "kirocrew skills authority-retire --reason --help", + "kirocrew skills authority-retire --help | head -40", + "kirocrew skills authority-retire --help 2>&1", + ): + assert security.is_denied(cmd), cmd + assert security.is_denied("kirocrew restart --help") + for cmd in ("kirocrew skills --help", "kirocrew skills list", "kirocrew skills root"): + assert not security.is_denied(cmd), cmd + def test_self_protection_denied_under_interposed_redirection(self): """A redirection is removed from argv by the shell and can sit anywhere in a simple command, so it must not shift the leading subcommand. diff --git a/test/test_security_gate_liveness.py b/test/test_security_gate_liveness.py index 7e33f7d00b0..c954020e78c 100644 --- a/test/test_security_gate_liveness.py +++ b/test/test_security_gate_liveness.py @@ -463,7 +463,14 @@ def _url_payload_command(n: int) -> str: #: exemption. No pass widened, no threshold moved, and the exfil URL scan is unchanged. #: Six more for its ``keep_sri_digests=False`` switch: a streamed segment whose delta #: boundary split an SRI value saves main's verdict on the digest, not the notice. -_PACKAGE_LINE_BUDGET = 32_188 +#: +#: Raised again, from 32,188, for the ``self-protection-skills-authority-retire`` floor, +#: which denies the operator-only ``kirocrew skills authority-retire`` recovery +#: through an agent shell: one argv matcher in ``argv_floor_self``, its facade +#: manifest line, its rule id and reason in ``denied_rules_tables``, its ``deny_gate`` +#: registration, its facade export, and its name in the ungated-floor comment. No pass +#: widened and no threshold moved. +_PACKAGE_LINE_BUDGET = 32_206 #: Ceiling on any ONE file in the package. This is what the bound is really for -- #: a package total says nothing about a single file growing back into a second