# Security Module ## Overview Kiro Crew implements defense-in-depth security across multiple layers: OS-level process isolation, credential path protection, input/output validation, authentication, authorization, and audit logging. This document consolidates all security controls and the vulnerabilities they address. ### MCP launch authorization leaves The `approvals.json` file in the crew-home `mcp-launch-approvals/` directory records operator-approved fingerprints for stubbed MCP launches. A fingerprint covers the declared environment text, so a changed `${VAR}` value keeps an approved launch approved only when the environment sidecar publishes; a failed sidecar pass persists no rebind. A changed declared text does not keep approval. A command, argument or declared-env path segment that is byte-identical to one of three spellings the gateway computes from itself -- `sys.executable`, the `deps_boot` shim path, the `kiro_crew` package's parent directory -- is hashed by role, so the approval names the gateway's own interpreter and package rather than the versioned directory a release places them in. Each folds only in its own slot: the interpreter only as the command, the shim only as an argument, the package directory only as a `PYTHONPATH` segment; the same string anywhere else hashes as written. There is no prefix containment and no case folding: a token either IS one of those strings or hashes as written, so no agent-chosen spelling reaches a different file through the fold. The encoding opens with a byte no UTF-8 token can contain, so no spelled-out argv or env value collides with it. An approval of such a launch recorded before the encoding existed is refused once as `changed_needs_reapproval` and re-approved by the operator; the store never admits the pre-encoding digest. The residual: `${VAR}` values come from the gateway environment, so an unsealed source of it (such as a writable shell rc file) changes an approved launch's values silently instead of surfacing as a refusal. Every writer publishes the store by rename, so the seal is its DIRECTORY: the file-tool gate denies writes under `mcp-launch-approvals/`, and the OS sandbox mounts that precreated directory read-only with a strict no-symlink check. Empty and absent forms both approve nothing. A refusal displays each command with its redacted, bounded declared environment. Every identity serialized into `expected_launch` has exactly one displayed command and one displayed environment, so the operator can approve only launch content that was rendered. Cached recommendation seeding can add a stub route, but it does not approve the launch behind that name. The generated `mcp-gateway/agents/` overlays and `mcp-gateway/stubs/` sidecars are not sealed: gatewayd checks the command and environment they carry against the approved fingerprint at launch, so tampered content fails closed. The `mcp/resolved/` tree supplies the executable substituted for a resolved npm launcher. It is file-tool write-protected, OS-sandbox read-only, and a strict no-symlink mount target. Linux precreates it before bind mounting it, so a fresh data home has the same disposition as one that already contains resolved launches. Its writers are gateway-side. The resolve-once npm child stays sandboxed and installs into a private directory under the sealed `run/` parent. The existing validated runtime carve-out grants that child write access to only the random staging directory; it cannot cover `record.json` or another resolution. After validating the tree, the gateway process renames it into `mcp/resolved/` and writes `record.json`. ### Member memory boundaries Cold subagent continuation restores app ownership from the canonical `subagents/` run records; retained V1 runs can still read their existing `member-memory-bindings/` sidecar. Both roots keep ordinary sandbox read-only protection, including empty-root precreation and refusal of redirected roots, and writes through the agent's file tools stay refused for both. They differ on the READ side, and what separates them is the record's contents rather than its position. `subagents/` is on the file-tool write-only gate, so its results remain readable. A `member-memory-bindings/` record carries the RAW session key it binds, so the leaf is on the read+write floor (`_CREW_SECRET_LEAVES`): the agent's own file tools may not open one, and `agent_sdk.tool_gate.adapter_hidden_credential_dirs` projects that floor into an enforced adapter's OS credential mask. The one legitimate reader, `subagent_persistence.read_run_execution`, opens the sidecar directly in the gateway and never consults this gate, so cold continuation of a retained V1 run still resolves its app owner, and Gateway writers remain functional. This protects app authorization integrity without a separate grant, duplicate execution record or cross-member read restriction. Two crew-webview leaves carry their own dispositions. `crew-panels/` holds the per-crew published panel record and is HIDDEN from agent processes, precreated before the sandbox spawns for the same reason `memory_stores/` is: a directory that appears later cannot become visible in an older namespace. `panel-templates/` holds the human-authored template and is exposed READ-ONLY, and is additionally listed in `security._WRITE_PROTECTED_HOME_PATHS` rather than on the read-plus-write floor, so the operator who authored a template can still read it back through the agent file tools and the dashboard viewer while no agent can rewrite it. The asymmetry is the point: the separation between the operator's template and the crew's published data is what the containment story rests on, so the write is the threat and the read is not. Both are also in `sandbox._CREW_NO_ALIAS_LEAVES`, which REFUSES the spawn when the leaf is reachable under a second name — a symlink, or a regular file carrying an extra hardlink. Every other SEALED ceiling only warns and continues, because a user who symlinks a config file into a dotfiles repository is doing something ordinary and refusing would turn a normal setup into a spawn failure over a pre-existing hole. Neither of these is a config file and nothing has a reason to link either one, so the weaker outcome is not worth its cost here: `crew-panels/` is bind-masked, so an alias attaches the mask to the target while the link name stays writable, letting a sandboxed process drop its own directory and forge records the gateway reads back as authoritative past both the ownership check and the redactors; and replacing `panel-templates/` is authoring markup that renders in the panel rather than changing a setting. A warning was what made this silent — the log said the path was sealed while the writes went elsewhere. Gateway-validated ACP effort markers live under `crew-panels/validated_effort_levels/`. The existing `crew-panels` mask is precreated and follows a relocated data home; the agent file-tool floor also fences that whole parent. The panel record reader uses flat `.json` names, so the marker subdirectory is outside its record namespace. Reusing this masked parent avoids adding another root-level leaf held only by its own mount name. The MASKED leaves are a separate population with a separate pass. `sandbox._refuse_aliased_masked_leaves` refuses a SYMLINK at every entry in `_CREW_HIDDEN_LEAVES` except the ones in `_CREW_ALIAS_TOLERATED_LEAVES`, and it runs last on the spawn path so a leaf carrying its own tailored refusal (`live_target.json`, whose sentence is shared with `kirocrew doctor`, and the md-notebook state leaves) answers first and keeps its own wording. It creates nothing, so an absent store is skipped rather than materialised, which is what lets one pass cover the masked leaves nothing precreates, including the retired `ledgers` root that must not be re-created on every machine. It checks the leaf AND every component below the data home: `lstat` un-follows only the final component, so a link planted at an intermediate of a multi-component leaf (`apps/aws-control/data`, `apps/meetings/data/edits`) would otherwise land the mask on an attacker-chosen tree while the lexical name stayed replaceable. The data home itself and its parents are deliberately not walked: a symlinked data HOME is a supported layout (a design rule of this spec; `config_dir()` resolves it without refusing a link). **That pass decides ABOUT a name; the mount it protects binds an OBJECT, and the two are kept the same object on both sides.** The pass runs in the gateway and the mask mounts in the launcher child, so a name swapped between them is judged once and bound twice. `_pin_mount_path` resolves each mask target ONCE into an `O_PATH` descriptor, classifies it with `fstat` on that descriptor, and the mount takes `/proc/self/fd/` — a path that names the object the descriptor holds however the name reads by then. **The expectation is CARRIED from the gateway, not taken again in the child.** A look taken inside the launcher lands on the far side of the script build, the `mkstemp` that writes it and the `fork`/`unshare`, so an occupant read there and compared there answers about the same instant twice and closes nothing. `_refuse_aliased_masked_leaves` already `lstat`s every crew hidden leaf to refuse an aliased one, so it records what it saw at no extra syscall; the four materialisers' established targets and `~/.ssh` are added beside it. The builder serialises that map as `mask_occupants` and probes nothing, which is the property `test_the_builder_does_not_stat_the_hidden_paths` pins for it. `_pin_mount_path` then looks its OWN target up in that map rather than taking an `expect_occupant` argument from each caller: every hiding mount reaches that one function, so binding the check to the function makes a new call site covered the day it is written, with no keyword for it to forget. **Not every masked target carries one.** Only the targets a pre-spawn pass observes carry an identity; most of a strict spawn's targets are not observed, including `~/.aws`, `~/.gnupg`, `~/.kube`, `~/.docker`, `.config/gcloud` and the read-only ceiling set. Requiring a carried identity for a name no pass observed would mean refusing a link there, which is the `stow` cost above charged on exactly the directories a dotfile manager symlinks. Observing every target in the gateway is the other tempting answer and is worse: it puts a per-target probe per spawn back on the single event loop, which is the defect `test_the_builder_does_not_stat_the_hidden_paths` exists to describe. So a name with no carried entry keeps the previous behaviour -- the mask covers what the link resolves to -- and that residual is stated here rather than left to read as covered. **The comparison is on DEVICE AND INODE, and it carries NO exceptions.** Weaker attributes do not identify an object: comparing only link-ness admits a same-kind decoy, because a directory swapped for another directory satisfies it while the real tree sits unmasked at whatever name the writer moved it to. Only device and inode identify the object. **One directory reaches the launcher under one name.** On a host whose home is a link (`/home/u -> /mnt/home/u`), the tier lists' `$HOME/` spelling and `config_dir()`'s resolved spelling are two names for one directory. `sandbox._crew_home_alias_roots` resolves each `$HOME`-joined crew root once, in the pass that already stats those roots, and the builder folds the alias spelling onto the canonical one (`crew_home_aliases`), so the launcher is handed one name per directory. The identity the fold rested on travels with each pair, and the launcher re-reads each alias before it places a mask and refuses unless it still reaches the recorded directory. A bind mount of the data home at `$HOME/` shares its `(st_dev, st_ino)` but is a second mount, so it is not folded and keeps its own entry. **One changed occupant is the mask itself, and the launcher knows it by identity (defence in depth).** Should a mask list still carry one object under two spellings, the launcher masks the first spelling by binding a stand-in over it and the second spelling then reaches that stand-in, whose identity is not the recorded one. A pin that judged that by identity alone would read it as a swapped object and refuse the spawn. So the launcher records, in `own_stand_ins`, the `(dev, ino)` of every stand-in it creates mapped to the `(dev, ino)` of the object it is bound over -- read off the pinned descriptor the mount goes through, at every mask loop, before the mount. A changed occupant that IS a stand-in registered against the very object this name's expectation carries is the mask in place, and the pin skips the name. Both halves of that condition are load-bearing. Only the entry AT the name counts, read no-follow, never a link's referent: a same-UID writer can plant a link at a protected name aimed into the directory that holds the stand-ins, and following it would read as "already masked" while the real tree sits renamed aside. And the stand-in must be the one bound over THIS object, not merely one this launcher made: the stand-in source falls back to the system tempdir when no tmpfs is on a separate filesystem, and there the same writer can rename an enumerable stand-in onto a protected name -- a stand-in registered against a different object refuses like any other swapped-in directory. The stand-in registered against this object sits on a mount over this object's own entry, which no rename moves (`EBUSY` on a mount point), so a match can only be the launcher's own mount reached by another name. **An ABSENT name under a directory the launcher already masked is that same mask, seen from below.** A mask list carries a leaf together with a directory above it -- a readiness probe hides the whole data home while every crew hidden leaf under it stays listed, and a symlinked `$HOME` lists both spellings of each -- and once the directory's stand-in is bound the leaf is gone from every later look at its name: the directory loop reaching it after its parent, and the file loop, which is offered every directory entry and always runs after. The leaf's expectation is still carried, so the absence branch alone would refuse it as a vanished object, and with the whole-home hide every probe spawn would refuse. So the launcher also records, in `masked_names`, every NAME it has confirmed reaches one of its own stand-ins -- each mask the loops mount and read back, and (as defence in depth beside the alias fold) each second spelling the pin finds already covered -- and an absent name is skipped when a proper ancestor of it is recorded there AND, resolved again now, still reaches the stand-in recorded for it (`_covered_by_own_mask`). Names rather than identities, because the second spelling of a masked directory holds no mount of its own and no expectation to compare a stand-in against, yet covers the leaves under it all the same. The re-resolution is load-bearing: the record says what the ancestor reached when its mask was placed, and the question is what covers the leaf at this instant, so a recorded ancestor that no longer reaches its stand-in refuses the leaf rather than walking higher. A link at the ancestor is accepted only when it is the link the pin itself followed, as the read-back accepts it. A private window met on the way up ends the walk with a refusal: the window mounts the real tree back over the stand-in, read-write, so a leaf beneath it (`apps/meetings/data/edits` under the `apps/meetings/data` window) resolves into that real tree and no stand-in above covers it -- its absence at the nested re-hide is the leaf having moved, and the launcher records every window it binds (`bound_windows`) so the walk can tell. The builder's lexical subtraction of nested `required_mask_targets` remains: it answers for a target whose ancestor is in the mask LIST, this answers for one whose ancestor has a mask IN PLACE, and the second spelling is in the second set but not the first. The caller-mask primitives the launcher keeps for a caller's own private windows are: a window is bound back over its masked tree through a pinned descriptor; on Linux a masked leaf nested inside a window is re-masked at the window's own path (pinned and verified like every other hiding mount); the window's staging mount is then retired with `MNT_DETACH` (`_retire_stage_or_die`), and the spawn refuses if it cannot be, because a surviving stage is a second unmasked path into the window; and a caller may pin a window to an expected `(dev, ino)`. The cost of having no exceptions is that EVERY inode change at a protected name refuses, and some inode changes are ordinary rather than hostile: | Change at the name | Outcome | Ordinary cause | |---|---|---| | untouched | proceeds | -- | | real object recreated | REFUSES | a staging directory rebuilt mid-flight (`aws-control-staging`, measured) | | real object atomically rewritten | REFUSES | any `atomic_write` / `os.replace` publish; the helper renames onto the destination, so the inode always changes | | symlink recreated | REFUSES | a dotfile manager restow | | real object replaced by a symlink | REFUSES | the substitution this exists to catch | | symlink replaced by a real object | REFUSES | a dotfile manager unlinking | Which of those a sandbox should permit is a decision about what the operator's own tooling may do to a protected name while an agent is running. It is not derivable from the launcher, and a list invented there would either break ordinary hosts or quietly reopen the hole, so the code carries none and says so at the comparison. This table is the list to choose from. **The FIRST look does not follow, and a link is still followed once -- those are two different statements and both are needed.** A link occupying a protected name has two unrelated causes wanting opposite answers: an ordinary `stow` or `chezmoi` layout has had one there since before the gateway started, and refusing it fails every strict spawn on a supported machine; a link SUBSTITUTED for a directory while the launcher looks is a redirect, and following it masks the planter's decoy while the real directory, renamed aside, stays readable. Nothing at a single instant separates them -- both show a link -- so the launcher does not try. `_pin_mount_path` opens the leaf `O_PATH | O_NOFOLLOW`, which does not refuse a link but returns a descriptor on the link ITSELF, and compares the identity of whatever occupies the name against the expectation `_carried_occupant` looks up in `mask_occupants`, refusing when it finds a DIFFERENT occupant. A link that was already there is the same link at both looks and passes; a directory replaced by a link is not. `O_DIRECTORY | O_NOFOLLOW` would refuse a link outright instead, and that refusal lands on the supported layout rather than on the planter, which is why it is not used. Once the occupant is known, a link is followed exactly ONCE so the mask covers the store the keys actually live in, as the `isdir`/`isfile` guards it replaces did, so a supported symlinked data home keeps working. Pinning alone closes only half the window: with the mask on the inspected object, a rename leaves the NAME reaching the racing writer's replacement — not a leak of what was there, a WRITABLE object at a protected name, which for the leaves the gateway reads back as authoritative buys forged records. So `_verify_masked_name` re-resolves the name after each hiding mount and REQUIRES it to reach that mount's stand-in. The read-only ceiling seal reaches the same invariant by the same step in the other direction: its remount can only name the mount its bind just created, so it re-resolves once and requires the object it reaches to be the object it bound. **Five refusal classes on the spawn path each fail CLOSED**: a target that exists and cannot be pinned (`open` denied where `stat` succeeded), a masked name whose occupant changed between a caller's first look and its pin, a masked name that does not reach its stand-in afterwards, a ceiling whose identity changed between being bound and being sealed, and a protected target that was present pre-spawn and absent by the time the child mounts. That last one is decided in two places, and the split is forced rather than stylistic. WHICH targets were seen present is recorded by the pre-spawn passes themselves, at every branch where they accept an object — one they created, one already there, and one a concurrent creator won and they then re-validated — because those passes are already statting and creating off the event loop. The launcher builder is handed that set as DATA and probes nothing: `test_the_builder_does_not_stat_the_hidden_paths` pins that it must not, since on a stalled home a single probe there blocks the one loop every session, cron and heartbeat shares. Then the builder makes the one judgement that is purely LEXICAL — it subtracts any target nested under a directory the launcher masks EARLIER, because that parent's empty mask is what hides the child, so the child's absence at pin time is ordinary and not a race. **The predicate is that distinction, not presence.** Requiring a nested target refuses every spawn on a host that merely has the parent store, and no filesystem answer can tell the two absences apart — only the path relationship can. A target NOT in that set AND with no carried occupant, or one holding the other kind of object, still SKIPS as the plain guards did — every caller-supplied path is offered to both the directory loop and the file loop and each takes its own kind, and requiring the whole list would fail every spawn on a host that simply does not use one of those tools. A target with a carried occupant is judged by it when the name is EMPTY as well: a pass recorded an object at that name moments ago, so an absent name is that object moved, and the pin refuses it as it already refuses a dangling link whose referent vanished. `~/.ssh` is the case that forces this -- the strict tier records it and nothing else marks it required -- so its block is entered on `lexists` OR a carried identity, and the pin refuses the absence. **The requirement covers ABSENCE only, and that separation is load-bearing rather than tidy.** Several established targets are regular files that also travel in the directory list, so a requirement that refused the wrong-KIND miss too would have the directory loop kill the launcher over a file the file loop masks correctly, on the ordinary first spawn against a fresh data home. Being established says the object is still there; it says nothing about which loop is meant to mask it, so only the loops' own kind test may answer that. `~/.ssh` follows the same split by a different route: its guard is `lexists`, which sees an occupant of any kind, so the pin declining the name is read back against the name itself. A name that has EMPTIED since the guard is a race and refuses; a name still occupied by something that is not a directory (a dangling link, a link to a plain file) holds no key directory to hide, and skips with a stderr warning rather than failing every strict spawn on that host. `sandbox_level` remains the deliberate opt-out for a host that cannot mount. **Documented residual: the pin FOLLOWS symlinks, so a link planted at a protected leaf is masked at its target while the link name stays replaceable (#13802).** `mount(2)` follows symlinks in its target exactly as `O_PATH` does, so mounting by name and mounting a pinned descriptor reach the same object here and this is inherited rather than introduced by the pinning. The post-mount name check (`_verify_masked_name`) does not change that for a pre-existing link: it reads the name no-follow first and accepts only the mounted stand-in itself or the very link the pin saw and followed, so a link planted at the name and aimed at the stand-in is refused. The residual is therefore limited to names that carry no occupant expectation, where a link already present is followed and masked at its target. Following is deliberate, because the `isdir`/`isfile` guards it replaces followed links too and a symlinked data home is supported. `_refuse_aliased_masked_leaves` refuses a link at every non-tolerated hidden leaf, so what remains is the window between that `lstat` and the child's mount; closing it needs an `O_NOFOLLOW` per-component descent inside the generated launcher, which cannot reach the `pinned_fs` helpers. **Documented residual: the write carve-out still resolves its name twice.** The `extra_writable_dirs` pair (bind, then remount read-write) is the one mount left on the by-name form. It WIDENS access inside an already-sealed subtree and degrades open by design, so a lost race costs an MCP probe its writable temp directory rather than exposing a credential, and its own `islink` refusal already rejects a link planted where the directory belongs. Recorded here rather than left implied, and pinned by `test_write_carveout_still_resolves_its_own_name_twice`. **Scope: the Linux bind-mask path, plus the credential-leaf pass.** The aliased-leaf pass is called from `namespace_argv`, so it governs the Linux namespace launcher. The hard-link pass on credential leaves below also runs from `sandbox_exec_argv`. macOS fences the same leaves through Seatbelt subpath denies, which are path rules rather than mounts and hold for a name that does not exist yet, so whether a symlinked leaf there resolves outside the denied subpath is a separate question this pass does not answer. One exception is deliberate and has a test asserting it is NOT refused: a SYMLINKED `.env`, the operator's hand-authored channel-credential file and the clearest dotfile-manager case in the list. The extra-hardlink shape is split per leaf rather than tolerated everywhere. `sandbox._CREW_HARDLINK_REFUSED_LEAVES` names the leaves whose bytes are a usable secret off this host on their own — the token signing key, the refresh-token chain state, the auth SQLite store with its WAL, SHM and journal sidecars, `.env`, the md-notebook access token, the Mission Control secrets store, and the three browser session leaves — and every other masked leaf keeps the WARNING, pinned per leaf so the boundary fails a test rather than drifting. For a named leaf with a second hard link, `_refuse_multilinked_credential_leaves` (run after the staging-link reconcile and legacy temp sweeps) inspects every masked crew home and has three outcomes: - every other name is located under a masked crew home: those aliases are masked for this spawn and the spawn proceeds; - not every name can be located: whatever was located is masked, the spawn REFUSES in the live home, and a non-live home only warns (a refusal there would be reachable from inside a sandbox, since an absent home is masked by nothing); - on the path-only Seatbelt masks (`masks_are_path_only`), the live home REFUSES even when every name is located, because a path rule cannot follow a renamed parent. The refusing side pays a cost: `st_nlink` reports that a second name EXISTS and not where it is, so refusing also refuses a link `rsync --link-dest` or a hardlinking snapshot tool left OUTSIDE the sandbox, where it is harmless. For a credential leaf that is the right trade and the same one `_refuse_unless_sole_regular_link` already makes for the live-target pointer, and it is not left to arrive as a failed spawn — `sandbox.masked_credential_leaf_aliases()` reports the condition in `kirocrew doctor` first, in the refusal's own words (see [cli](cli.md), *Doctor Checks*). For a leaf masked only so an agent cannot WRITE it, its reader re-validates the content and a host-wide spawn outage is not proportionate to a write alias. `.env` sits on the refusing side even though its symlink is tolerated: that tolerance exists for the layout a dotfile manager produces, and chezmoi and stow produce a symlink or a copy, never a hard link. Only a REGULAR FILE can carry a second hard link — `link(2)` refuses a directory — so every directory leaf is outside this decision by shape rather than by judgement. Nothing here is silent, and that is part of the decision rather than an accident: a tolerated leaf is VISITED and logged, because excluding it from the walk would reproduce on the credential leaf exactly the silence the pass exists to end. The warnings are emitted by this pass rather than by `_warn_if_alias_backed`, which never runs over these leaves, and they fire per spawn for that function's own stated reason -- a host where this keeps happening has a real problem, and de-duplicating would hide how often the control cannot be established. A third case degrades rather than refusing: a planted link at an intermediate component of an md-notebook state leaf, where `carveout_chain_has_planted_link` already withholds the carve-out, so the owning backend cannot write that state and an unmasked leaf has nothing to expose; refusing there would let one optional app's on-disk layout stop every sandboxed spawn on the host. That case warns too. `scratch`, `backup` and `work` have no supported second name and refuse a link: each resolves to one managed path (`agent_scratch.scratch_root()` is `config_dir() / "scratch"`) with no override or env var, so a link there is not a relocation the product offers. `work` is the durable work root, `/work/`: `work_root.allocate_work` creates a key's directory and `sweep_work_root` reclaims idle ones. It is a hidden crew leaf that is precreated, so a sandbox spawned before the first allocation does not watch it appear. Masking a credential leaf as a FILE leaves its publish temp to account for separately, because a mask covers a path and the temp has a different one. The gateway's two auth stores — `token_signing.key` and `refresh_chains.json` — publish through `auth-store-staging`, a direct child of the data home that is masked (`sandbox._CREW_HIDDEN_LEAVES`), precreated so a sandbox spawned before the first write does not watch the directory appear (`_CREW_PRECREATE_HIDDEN_DIR_LEAVES`), and fenced from the agent file tools (`security.paths._CREW_SECRET_LEAVES`) — all three as whole DIRECTORY entries, so every temp name inside is covered without a per-name decision. Staged beside the leaf instead, the temp sits in the data-home root, which is sandbox-visible and same-uid writable, and it holds the full key or chain state for the whole write: an agent listing that directory can `link(2)` it and keep reading after the publish rename. The hardlink pass above cannot see that window, because it runs before a spawn and the window opens during one. This is the same treatment, and the same reason, as `live-target-staging`, `md-notebook-staging` and `aws-control-staging`. The staging directory is validated rather than trusted on each publish: a symlink or non-directory is refused, and so is a group- or world-WRITABLE directory whose mode `chmod` cannot narrow, because another local account could otherwise replace the staged file between the payload write and the publish link and install a signing key of its choosing. A directory that is merely group- or world-READABLE is narrowed with `chmod` and warned about when that does not stick: a read bit leaks the temps' names and grants no substitution, and it is the `dir_mode=0755` default of a real mount class. On a staging refusal the gateway uses an ephemeral signing secret for that process with the persisted key untouched (it does not fall back to the in-place writer, which would create the key empty first), and the refresh-token store degrades fail-closed. A temp written before that directory existed is an artefact already on disk, which masking forward cannot reach, so both launchers sweep `.token_signing.key.*.tmp` from every crew-home spelling (`sandbox._sweep_legacy_auth_store_temps`) and refuse the spawn if one cannot be removed. A match is only removed once it is known not to be the key inode's last name: with the key present at its own name and a different inode it is removed outright; sharing the key's inode it gets `fsync_dir` on the root first, refusing where the device refuses that; and with the key ABSENT nothing is removed and the spawn PROCEEDS with the temp retained and reported as exposed, because that state cannot be told apart from a staged write that never published, so removing risks destroying a key an operator can still recover by renaming while leaving it would hand the agent a cleartext key. Refusing there is the third option and it is the one not taken: the absent-key state is reachable in a crew home the install does not use, which is masked by nothing while it is absent and therefore creatable from inside a sandbox, so a refusal would let the governed process stop every launch on the host. The `SECURITY:` warning names both recoveries on every spawn, `kirocrew doctor` lists the condition, and the next start mints a fresh key after which this sweep removes the temp outright. That sweep is bounded to names carrying the leaf, and the bound is load-bearing rather than conservative: the data home is shared and `atomic_write` stages `tmp.tmp` there for unrelated stores, so a wider pattern would unlink another component's in-flight temp between its `mkstemp` and its rename. A pre-upgrade `refresh_chains.json` temp carries that leaf-less name and so cannot be told apart from a live one, so it is not swept. The keystone-artifact suffix rule covers a `.tmp` or `.lock` name in a keystone leaf's own directory, but it lives in `security.paths` and gates the agent's FILE TOOLS only: no OS mask binds a `tmp.tmp` name in the sandbox-visible data-home root, so a shell inside the namespace can open one and read the consumed-JTI, revoked-chain and `chain_peers` state it holds. That is a stated residual, not a closed hole. It affects only a home carrying a temp from the earlier layout, nothing recreates one, and removing the file closes it. Memory V2 separates members' learning and work context; it does not promise confidentiality between agents running as the same host operator. One stable `member_id` owns one stable `store_id`, whose managed path contains one SQLite learning authority. Arbitrary code and external tools may read another member's files. Prompts and built-in path checks guide correct use and prevent accidental raw DB/WAL/SHM edits; they are not an adversarial file-isolation boundary. The ordinary Linux and macOS sandboxes expose `memory_stores/` read-only while leaving it readable. Built-in named-store mutations run in the gateway; sandboxed agent code has no direct writer requirement. Linux may create only the empty root to make the directory mount possible, never a database or member config. This reuses the normal read-only mount/write-and-link denial, with no per-member view, proof, hardlink scan or platform admission requirement. Existing named V1 root links retain their ordinary handling. The rule provides write integrity where the host sandbox is active; sandbox-off execution, external host tools and pre-existing writable aliases are not covered. Global V1 paths are unchanged. Normal memory tools use the authenticated caller's canonical execution record. The gateway captures it once per request and passes the same member/store/mode to checks, execution and background work. Display labels, templates and projects do not select a database. Missing member identity or an unavailable, corrupt or mismatched database returns a specific error, never an empty replacement or a silent Global V1 fallback. Only explicit member creation provisions a database. Delegation inherits the member unless an explicit target member is admitted by the ordinary delegation rules; continuation retains the original run member. Member-specific namespace/Seatbelt views, hardlink scans, PID ancestry proofs, HMAC capabilities and duplicate protected session/run grants are removed. Member memory does not require a particular OS sandbox or direct MCP topology. Shared MCP uses its ordinary authenticated, request-local caller metadata. Ordinary signed PID sidecars and broker caller validation retain their transport duties. Host sandboxing, credential masking, tool allow/deny decisions, HTTP/MCP authentication, dashboard-owner and application permissions, SEL integrity and mandatory enterprise governance remain independent controls. Explicit dashboard `?store=` access still requires owner authorization. Internal calls cannot choose arbitrary database paths or gain owner aggregate controls through a memory binding. Local owner-token bootstrap still requires positive host provenance or a live application backend owned by this gateway; an unknown peer is refused. Linux namespace and macOS sandbox queries retained for that bootstrap are host authorization checks, not member-memory isolation. On Linux, a CLI peer in a different user or mount namespace is refused unless it is a live application backend tracked by this gateway. A script cron child is such a peer, and so is the `kirocrew token` it shells out to: `run_script_sandboxed` launches every script cron through `wrap_argv`, so the child never shares the gateway's namespaces. That refusal is kept deliberately rather than given a cron-registry accept branch of the app-backend shape. A cron body is agent-writable, and an owner token reaches the keystone writes under `/api/security`, `disable_all` among them, so a live-cron exception would let an agent switch off its own denied-command rules from a cron. Script crons drive the dashboard with their own internal-secret credential instead: `ScriptContext.open_session` and its siblings in [learn-cron-dashboard](learn-cron-dashboard.md). That secret is not admitted to `/api/security` or `/api/governance`, and the internal branch sets no `user` claim, so the owner gates refuse it. Pinned by `test/test_script_cron_owner_bootstrap.py` and `test/integration/test_script_cron_sessions.py`. This intentional owner-token bootstrap restriction also applies to installations with no members. Container, Snap and Flatpak CLI login across namespaces is not claimed as verified. Context assembly reads the member, manual rules, briefing, execution template and project documents directly. It does not open SQLite to discover ownership. Source validation, bounded reads and deduplication remain; globs skip managed state, while an explicitly invalid source reports its error. Optional learned context is omitted with an availability diagnostic if its database cannot open. Delivery receipts belong to a specific session and provider incarnation, and are committed only after successful delivery. A chat with native context cannot switch member in place; unused-chat selection coordinates prewarming, resume pointers and late provider results before binding the new execution. Incognito allows memory reads but no session-induced learning writes; Temporary allows neither, including automatic lessons. Recall has no persistent side effects. Child, continuation, workflow and task records cannot loosen the mode. Restricted payloads stay out of Crew checkpoints, progress files and extraction. The shared MCP audit wrapper records tool, session and outcome for memory, delegation, task, workflow, cron-write and hook-registration calls without their query or body arguments, including validation and execution failures. Independent authorization events and audit-chain integrity remain intact. Retention mode is established before provider startup and recording. Crew's native-session cleanup is a lifecycle operation; external providers' own storage and crash behavior remain provider-specific, not a new sandbox guarantee. See [memory](memory-skills-hooks.md), [session](session.md), and [subagents](subagent.md) for storage, selection and retention details. ## Module layout The security controls ship as the package `src/kiro_crew/security/`. `kiro_crew.security` remains the only import path for the split controls: `security/__init__.py` is a facade that re-exports every name the rest of the tree and the tests reach, so a caller never names a submodule. Submodules hold one responsibility cluster each, and the facade is what keeps that split an internal detail rather than an API. One module on this surface sits outside the facade by design: `readonly_bash.py`, the read-only bash classifier, is imported by its own path (`kiro_crew.security.readonly_bash`) and is not re-exported -- see its entry below for why. Two mechanisms make "the split changed nothing for a caller" a tested claim rather than a remembered one, pinned by `test_security_facade.py` and `test_security_single_storage.py`: - **A frozen export manifest** (`security/_exports.py`) lists every name the package exports, private helpers included. Each must resolve on the facade and be the SAME object the owning submodule holds — a re-export by identity, never a copy. The list is frozen rather than derived, because a derived list agrees with any facade; a name leaves it only when the symbol is deliberately deleted. - **Re-export by resolution, so the value has one home.** The facade binds no re-exported name. `_EXPORTS` maps each name to the SHORT NAME of the submodule that defines it, `_owner()` resolves that submodule (`sys.modules` for a module already loaded, `importlib.import_module` for the miss), a module `__getattr__` reads the value off the owner on every access, and `_ReExportModule.__setattr__`/`__delattr__` send a write or delete there. So the value lives only in the owner's namespace and the owner only in `sys.modules`, which is what makes a patch, a replaced module and a purged-and-reimported module all visible at once, in one direction, with nothing to keep in sync. The facade's own code cannot use `__getattr__` — a function defined here resolves a bare global through this module's namespace, which never reaches it — so those functions ask `_submodule()` for the owner and read the name off it, once per scope. - **The boundary: a name this package does not define is an ordinary attribute of it.** `_EXPORTS` covers only names a submodule of this package defines. A name the facade imports for its own use from the standard library or another `kiro_crew` package (`re`, `Path`, `SecurityEventLog`, `path_resolve_executor`) stays a plain attribute here, so a write to it lands here and reaches no submodule — a patch site for such a name names the module that READS it (`security.exfil`, `security.paths`), the same conversion a cross-submodule name has always needed. - **An unresolvable owner denies rather than answers.** Resolution can fail where a binding could not, so the failure modes are kept distinct on purpose: a name in the table whose owner will not import raises `ImportError`, and only a name that does not exist raises `AttributeError`. `getattr(security, "is_sensitive_path", None)` swallows the second and lets the first through, so an unresolvable gate cannot become a `None` a caller reads as "not sensitive". `_submodule()` also reads `sys.modules` before importing so that a caller who rebinds `importlib.import_module` — which tests do, for unrelated reasons — cannot reroute a gate's read to their own object. ### Submodules - `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: statement and word splitting, the quote and escape state machine, redirection and substitution peeling, argv attribution, printer-escape decoding, 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. It decides nothing, and imports only `vocabulary.py`, the layer below — 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 declarations, bounded resolver, read/write path gates, and the command-line orchestrator. The command-line gate deliberately matches no paths; it enforces only the scan-size ceiling, IMDS access, and environment-credential exfiltration. Layer-one path declarations stay importable below the egress and rule-catalog modules; the orchestrator reaches those upper layers through call-time imports to avoid a cycle. The resolver is bounded because a path check runs synchronously on the event loop against an agent-supplied token, and a stall fails **closed** for every path under the stalled prefix until the mount answers again. The refusal a stall produces is worded as what it is: `sensitive_path_refusal` is the path tier's reason-or-`None` producer (the shape the bash, exfiltration and deny-rule tiers already have), `is_sensitive_path` is its `is not None`, and a stall answers "could not be verified against the sensitive-path list ... NOT a match ... retry the same call" where a match answers `access to sensitive path`. The decision is identical either way; only the words differ, because a stall reported as a match sends the agent reading it after a credential in an ordinary project file, or to a re-spelling that meets the same budget. `deny_guidance` classifies that refusal FIRST and structurally -- `is_unverifiable_path_refusal`, a test of the fixed prefix the producer exports, which precedes any caller-influenced text -- never by searching the text, because both refusals quote the agent's path verbatim and a path spelled to contain the stall wording would otherwise move a real match into the wait-and-retry class (and past `safe_read_file`'s repr re-spelling, the log-forgery guard). Its remediation is "wait, retry the identical call" rather than any credential remedy. Because that blanket refusal is the expensive conclusion, two things bound what earns it. A missed budget is not itself a stall: the resolution gets a bounded grace — a capped fraction of the caller's own budget — to finish, and one that finishes is a success that charges nothing, which is the only discriminator available on hosts where the syscall probe cannot say WHY the budget was missed (every Windows host, since `platform.machine()` reports a name absent from the syscall table). And the prefix a stall is charged to splits the DRIVE off before counting components, so a Windows path keys on `C:\Users\` rather than collapsing to `C:\Users` — a key that contains `$HOME`, `%TEMP%`, the workspace and the checkout, and so turned one slow resolution into a refusal of essentially the whole host. Each budget is also sized to the work its caller submits: the anchor REBUILD is one job doing ~130 `realpath` calls and carries its own, larger budget, where a candidate resolution does one or two. Across all prefixes and anchor jobs, each calling thread shares a 12-second cumulative allowance for actual result waits, including successful waits, timeouts and grace, but excluding a wait below a 100ms floor: that floor is resolver-pool round-trip overhead rather than filesystem latency, and without it ordinary bulk work (a project-tree listing, a knowledge-indexing pass, a directory-wide scan) accumulates thousands of sub-millisecond on-time waits and exhausts the allowance with zero mount evidence. Both waits are clamped to the remaining allowance; exhaustion refuses without submitting or charging a prefix. A timeout whose budget or grace was clamped short by the allowance also refuses without charging a prefix, since it establishes nothing about the mount; only a timeout that received its full entitled budget and full entitled grace can charge the prefix. Spend expires only after 25 seconds without a positive-duration resolver wait, so adjacent windows cannot combine into the 25-second watchdog gap. This deliberately fails closed for otherwise healthy paths when their thread has spent its allowance, leaving 13 seconds for heartbeat age and other tool-call work. Background callers cannot consume the loop thread's allowance, and bookkeeping cleanup removes only expired thread entries. - `denied_rules.py` — the configurable tier: the built-in denied-command catalog, the reverse pattern-to-id map, the two governance pin accessors that read it through the legacy-spelling alias map, the effective-list resolver, the inert-search-verb exception surface with its fail-closed eligibility gate, the linear matcher, and the single producer of refusal text. Four things here are load bearing beyond their own tier. The floor tag sets and pattern tables are tied to catalog rule ids so the matcher and display rows cannot drift; the single ungated git-publish id is exposed explicitly by `floor_enforced_builtin_command_ids()`. The pin accessors sit with the reverse map because a pin is a pattern STRING a policy persisted, and only that map turns it back into a rule id; they stay two accessors rather than one, because the enforcement side resolves the ACTIVE ceiling alone while the display side over-locks across every loaded profile, and unioning at the enforcement side would force one profile's pin onto every other. The matcher is an evaluation-layer rewrite only — the rule patterns were authored for a linear-time engine and are not safe to hand to a backtracking one verbatim, so the catalog rows and the golden fixture the parity test pins to stay byte-for-byte unchanged and a refusal still reports the original pattern. The refusal producer's first line is frozen because two consumers parse it, one with a per-line end-anchored regex, so an operator note goes on a second line that both ignore. It imports the shell reader only. The environment credential tier sits here too, and for a structural reason rather than by subject: it re-enforces two catalog rows and resolves their ids to row objects in a module-level comprehension, eagerly so an unresolvable id fails at import rather than silently retiring an always-on block, which makes it catalog-side by construction. The evaluator and the audit emitters still sit in the facade: the evaluator reads the argv floor's predicates, and the floor reads one sentinel this module owns, so the evaluator cannot land here without making that pair a load-time cycle — its home is a layer ABOVE the floor, or the sentinel moves down to `vocabulary.py` first. - `redaction.py` — the output side, and the widest external surface in the package: the credential alternation with the pre-filter that gates it, the entropy machinery behind them, the redaction tag registry, the batch credential redactor and the host-path pass. The alternation and the pre-filter are ONE unit and are never separated, because the redactor SKIPS the alternation entirely when the pre-filter returns False — an input the alternation would have matched but the pre-filter rejects is a silent leak rather than a missed optimisation, so the pre-filter is a documented strict superset asserted by test. The entropy machinery answers a different question from the alternation: a bare high-entropy run carries no marker to anchor on, so it is judged by shape — length, character classes, entropy, decodability — with each gate a separate predicate so a refusal can name the one that fired. It imports nothing from the package. The streaming redactor, the combined `redact()` pass and the scan-then-truncate wrapper still sit in the facade, because each composes this module with the exfiltration-URL redactor in `exfil.py`, the layer ABOVE it: a composition over both sides belongs above the egress split, not inside either half of it. The one path-aware form, `redact_path_segments(path, redactor=None)`, does live here and takes the whole-string redactor as a parameter for that same layering reason: the egress call sites (the project tree and git-status listings in `dashboard/file_api/project_tree.py` and `dashboard/file_api/git_panel.py`, and the content-search rows in `dashboard/file_api/grep.py`) pass the context-aware `redact` shim, so a loaded companion's extra patterns apply, and the default is the credential pass alone. It redacts a `/`-separated path segment by segment and suffixes every segment the redactor changes with `~` and an opaque label: `HMAC-SHA256(_PATH_LABEL_KEY, segment)` truncated to 12 hex digits, where `_PATH_LABEL_KEY` is 32 random bytes generated once per gateway process (`secrets.token_bytes`), held only in memory and never persisted, logged or exposed. It never emits LESS redaction than the redactor it wraps: the segment-wise result is returned only when the redactor finds nothing left in it (a token spanning a separator is matched by no single segment), otherwise the whole-string result is returned unchanged; a path the redactor leaves alone is returned as is. Each call site calls it per path. Pinned by `test_redact_path_segments.py`, `test_project_tree.py` and `test_project_git_status_log.py`. The keyed per-process label is the one shape that satisfies all five properties those listings need at once: (1) distinct inputs stay distinct, so two genuinely different paths whose only differing segment is credential-shaped (two `AKIA…` filenames, two hash-named build-asset directories) stay two entries instead of collapsing to one placeholder that a de-duplicating listing then silently drops; (2) no byte of the secret is in the output, the tag replaces the token whole; (3) no UNKEYED digest of the secret, because a plain hash prefix hands a reader an offline dictionary oracle over a low-entropy token and the HMAC cannot be checked without the key; (4) no dependence on listing position or order, because the label is a function of the segment alone, so a sorted listing does not correlate it with the secret's lexicographic rank and a path labels the same way whatever else is listed with it; (5) stable across responses within one gateway process, because the dashboard (`PierreWorkspaceTreeImpl.tsx`) joins the tree response with the git-status response by path, and a per-response label breaks that join whenever only one of two colliding paths appears in the status response. The label changes when the gateway restarts, which is fine: both responses of one join come from the same process. The listings keep their post-redaction de-dup behind it as the fallback for a collision the helper does not separate (48 bits make an accidental one negligible within a tree). One masker sits here for the BINARY delivery scans alone, `mask_baseline_symbol_tables`. A standard baseline JPEG's Huffman symbol table has a 45-character printable tail that reads as six digits, a colon and thirty-two letters — an unlabelled bot token — so without it `platform/context.py`'s `binary_content_is_flagged` refuses essentially every image written with the default tables, including a blank 694-byte one. Masking is pinned to that ONE fixed constant, held as code-point ranges rather than a pasted literal because the literal is itself the credential shape, and fires only on a whole maximal region of text bytes EQUAL to a contiguous slice of it. That equality is the entire safety argument and it needs no reasoning about shapes: the only characters the masker can remove are characters of a public constant, so no uploader-chosen byte is removable, a credential-shaped run that merely resembles a table keeps its whole match, and a credential written beside a table shares the table's region and leaves it unmaskable. The region floor is the shortest slice any detector here flags — measured, not chosen — and the number of masked regions is capped, past which the buffer is returned untouched, which is the REFUSING direction. Safety needs two bounds, not one. What may be REMOVED is bounded by that equality. What the removal may BREAK is the half a slice rule alone does not cover, because a match can depend on the region without lying inside it, in two ways. It can ANCHOR ACROSS the region -- the non-text bytes delimiting it sit inside the value classes carrying no literal label -- and it can BORROW a required literal FROM the region, since the constant contains punctuation including `:`, which is exactly the separator `://[^\s:/@]*:[^\s/]+@` requires. So removing only PUBLIC characters can still destroy a match. Both are closed in `_mask_region`: only the region's ALPHANUMERICS are filled and its punctuation is copied through, so every literal the region could lend survives; and the filler is `~`, admitted by every boundary-crossing class (`[^\s/]`, `[^\s"',}]`, `[^\s:/@]`, `[\s\S]`) so a crossing run survives, and by no contiguous-token class (`[A-Za-z0-9+/]`, `[A-Za-z0-9_-]`, `[0-9]`) so a token run can only shorten. The split is on alphanumeric because that is what the unlabelled credential SHAPES are made of, so filling them cancels the table's bot-token shape while the punctuation the patterns need as literals stays -- a whitespace filler destroys a password spanning a table, and filling the whole region destroys one that borrows the table's colon. The text path deliberately does not use it: there a match costs a redaction tag, where on the delivery path it costs the file and the only way past the refusal is a durable class-wide grant; `test_outbox_binary.py` pins it. - `exfil.py` — credential EGRESS, the layer above output redaction: the URL and token layer that decides whether a URL carries a credential in its path or query, the safe-diagnostic family that reports such a finding as a rule id, a component and a character-class shape rather than the bytes it matched, the operator-extensible OAuth authorization-endpoint set, the data-egress and reverse-shell command gate, the metadata-address folder that collapses every alternate encoding of the instance metadata endpoint onto one dotted quad, and the metadata check built on it. It imports `redaction.py` and nothing else in the package, and that direction is the design rather than an accident: redaction decides whether a run of text IS a credential, this module decides whether a command or a URL is carrying one OUT, so the egress side reads redaction's shape predicates and redaction reads nothing here. The environment tier that re-enforces two catalog rows lives in `denied_rules.py` instead, because it resolves those rule ids to row objects at import time — a dependency on the catalog rather than on anything here, and eager so an unresolvable id fails at import instead of silently retiring an always-on block. The endpoint sanitizer is redaction's by responsibility but still sits in the facade: it consults this module's egress pattern set, so placing it in `redaction.py` would point the load-time edge back the wrong way, and those patterns move down a layer first. - `perm_verb_mention.py` — the inert-mention narrowing for the path-scoped `chmod`/`chown` deny rows: whether every occurrence of a permission verb is an ARGUMENT of a command that treats arguments as data. It sits above `argv_floor.py`, whose frame walk it reuses (see *Inert mentions of a permission verb* below). - `credential_sources.py` — where a redacted credential came from, recorded without keeping the credential: keyed per-process HMAC fingerprints of redacted values and bounded per-turn evidence naming the tool call that produced one (see the credential-records bullet under XPIA Hardening). - `hosts_file.py` and `host_addresses.py` — pure helpers for the ssh self-target floor in `argv_floor.py`: the hosts-file line parser and its digest, and the per-platform readers of this machine's own interface addresses. `argv_floor` owns every read, the cache and the gate. - `_child_realpath.py` — the symlink-resolution worker the bounded resolver in `paths.py` runs as a script in its own interpreter (`python -S`) through `kiro_crew.subprocess_pool`; never imported. - `inline_payload.py` — static mint-name checks for inline Python payloads selected by the argv floor; it selects its own interpreter token in each statement (`_names_an_inline_interpreter`). It folds constant string expressions, resolves supported call bindings with Python tokenization and AST parsing, decodes supported base64 literals, and asks whether the payload names a Kiro Crew credential-mint surface. It is a heuristic, never an interpreter or the OS credential boundary. - `argv_floor.py` — the always-on argv-structural floor. It owns three families: protected or ambiguous git-publish detection; Kiro Crew termination, restart, update, and destructive-cloud subcommands; and credential-mint predicates that follow inline programs, here-documents, stdin redirections, and nested shells. It also holds the ssh self-target floor (`_host_is_self`), backed by `hosts_file.py` and `host_addresses.py`. 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. - `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.`, 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 the deny tiers' dependency order (which has `perm_verb_mention.py` above the argv floor). Pinned by `test_trust_reads.py`. ## Threat Model | Threat | Vector | Mitigation | |--------|--------|------------| | XPIA credential theft | LLM reads `~/.aws`, `~/.ssh` via `fs_read` or `cat` | File tools are blocked by `is_sensitive_path()`. Shell access is bounded by the selected OS-sandbox tier: strict mode hides these stores, while standard mode intentionally leaves them visible for credential tooling; output redaction remains defense in depth | | XPIA data exfiltration | LLM embeds secrets in URLs posted to a chat channel or the dashboard | Output scanning + URL redaction | | Cross-origin WebSocket hijack | Malicious page connects to `ws://127.0.0.1:5476/api/ws` | Origin header validation | | Cross-origin mutation (CSRF) | Malicious page POSTs to dashboard API | Origin/Referer validation on non-safe methods | | DNS rebinding | Attacker domain resolves to `127.0.0.1`; browser sends forged `Host` to the loopback-bound dashboard (incl. GET exfil) | `Host`-header allowlist validation on every method (`check_host` / `host_validation_middleware`), deny-by-default, 403 + SEL audit. Sole exemption: the three `PROBE_PATHS` liveness probes (orchestrators address containers by IP); their handlers strip identity fields via a second `check_host` gate, leaking nothing beyond TCP reachability | | Unauthenticated remote access | Dashboard bound to `0.0.0.0` | Loopback-only by default (`127.0.0.1`); when user opts in via `dashboard.url`, token auth middleware requires HMAC-SHA256 signed, IP-pinned, single-use tokens on every request | | Unauthenticated remote access (AEA tunnel) | `tunnel.enabled` exposes dashboard via public HTTPS URL | Double auth: Tunnels validates Midway OIDC at edge + Kiro Crew token auth middleware. Security gate refuses tunnel start without token auth active. Owner-only access (Tunnels restricts by username). SEL audit on connect/disconnect/denial | | Published surface on a throwaway instance | A scratch instance (Dev Fleet pod) boots a gateway that can publish a tunnel, becoming reachable off the host and contending with a real gateway's registration. A seeded `tunnel.enabled=False` does not hold: it is written once at HOME creation and anything composing config later can flip it back | `--no-tunnel` boot flag pins "never publish" for the life of the process (`tunnel.set_publish_disabled`, read via `publish_disabled()`), consulted by BOTH doors out — `setup_tunnel` at boot, ahead of the token-auth gate and before any `TunnelManager` is constructed, and the on-demand provisioning in `slack.allowlist`, which bypasses `setup_tunnel` entirely. Config is deliberately not consulted: the flag is process state, so no file — including a `config.local.json` overlay — can turn it back on. A pod passes the flag on every exec whose target checkout declares it; the control plane builds the argv but the target worktree's gateway executes it, so `target_supports_flag` probes that checkout first and DROPS the flag when absent, because handing it to a gateway that predates it exits argparse 2 and `Restart=on-failure`/`RestartSec=5` makes that a 5s restart loop (the unit's `RestartPreventExitStatus` holds only the terminal boot codes 3, 70 and 78, not 2). SCOPE: such a checkout does NOT receive this guarantee — it keeps its pre-flag tunnel behaviour, which is a non-regression rather than a fix, and no config-side substitute is attempted. Re-pinning `tunnel.enabled=False` in the pod config is not a substitute: `KiroCrewConfig.load()` deep-merges `config.local.json` OVER `config.json` with the overlay winning and `config set` writes the overlay by default, so pinning one file does not pin the setting; and the gateway's enable test is an OR (`cfg.tunnel.enabled or current_context().tunnel.enabled()`) whose provider half no config file reaches. Pinning `KIROCREW_PROFILE=standalone` to reach that half is also refused — the profile selects the whole `PlatformContext` including the Level-1 governance ceiling, so it would skip an administrator's policy. Every refusal is SEL-audited — `tunnel.start_denied` at boot and `tunnel.provision_denied` on the on-demand path, both with `resources=no_tunnel_boot_flag` — and `/api/tunnel/status` reports `reason: "boot_flag"` | | Unauthorized dashboard access | No auth on localhost | Token auth middleware on all requests (loopback bypass removed); file-based IPC secret for internal paths | | Non-owner channel interaction | Any workspace/server member clicks YOLO/approve buttons | 5-layer owner verification | | Fail-open owner lock | `KIROCREW_OWNER_ID` unset → no check | Deny-by-default: refuse connect + reject messages | | MCP input injection | Malformed/oversized tool inputs from LLM | Centralized schema validation (`validation.py`) | | MCP response DoS | Unbounded tool output fills memory | Response truncation at 100K | | Destructive CLI commands | LLM runs `rm -rf /`, `git push --force` | Built-in denied-command rules (`BUILTIN_DENIED_RULES`, default-on / user-disableable) enforced at the hooks PreToolUse gate + governance `commands` force-deny (enterprise, un-opt-out-able); `SUSPICIOUS_BASH_PATTERNS` is a separate advisory history scan, not an enforcement layer | | Frontend XSS | `dangerouslySetInnerHTML` with unsanitized content | DOMPurify + safe DOM APIs + Mermaid `securityLevel: 'strict'` (HTML in diagram text encoded, click handlers disabled) | | Widget postMessage forged turn | LLM-emitted `