feat(aws-control): a Crews pane for the crews you deployed - #8470
feat(aws-control): a Crews pane for the crews you deployed#8470chenmingwei23 wants to merge 1 commit into
Conversation
QA evidenceCaptured against fixtured routes in an isolated instance, no AWS credentials. The Each state below is visibly distinct from the others, which is the point: two of them
Row alignment is measured in a real engine rather than asserted in jsdom, which |
UX Review (Fable 5) — 🟡 CONCERNSUX-level review of The screenshots are clean and the pane matches the product's established rail/card patterns (the card is a faithful sibling of the Agents page's UX-Verdict: CONCERNS Solid, product-consistent read-only pane; but both empty-state repairs are unclear — one offers no next step, the other says "ask your crew" for a crew. Watch
Evidence gaps
Suggestions
[UX-REVIEWED] ecc0286 |
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of Design-Verdict: CONCERNS Sound, unusually honest design — but ~20k lines of billable-infra deploy machinery ships to users with zero real-AWS validation, and persistent mode is deployable with a known data-loss defect. Watch
Suggestions
[DESIGN-REVIEWED] ecc0286 |
bc3e285 to
1f2b3cf
Compare
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of First-Principles-Verdict: CONCERNS The shipped docstring still claims a "driver proven against a real account" — the exact sentence the description retracts as false — and a zero-consumer placeholder module rides along. What this change shipsIntent: let an owner see, in AWS Control, the crews they deployed into their own AWS account — and land the deploy machinery that creates them. ADDITION.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] ecc0286 |
GPT 5.6 Review — ✅ human override acceptedHuman judgment by @chenmingwei23 overrides the GPT 5.6 finding for This comment is updated in place on each push. The model was not re-run because an authorized human decision supersedes it. False positive or not applicable? A repository writer can comment: |
2441766 to
33c6739
Compare
33c6739 to
f1c079d
Compare
f1c079d to
c75dfc8
Compare
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsNo blocking issues; one advisory finding. FINDING — src/kiro_crew/apps/builtins/aws_control/crew/packaging/build.py:1347 — [OPUS-REVIEWED] ecc0286 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
c75dfc8 to
a70035c
Compare
Addressing the GPT 5.6 reviewFour of the five blocking findings were real and are fixed in
The more useful half of this finding is why it survived a gate whose entire job is this class of defect. That mattered because this gate's reverse direction had already been silently vacuous once: it scanned for One judgment call worth surfacing rather than burying:
Both ship.
<button onClick={onBack} data-testid="crew-detail-back">
<ChevronLeft size={14} aria-hidden="true" />
<span className="min-w-0 truncate">{i18nT('apps.awsControl.crews.title')}</span>
</button>So the absent state shows that button, and then "This crew is not in this account any more. It may have finished deleting." Returning to the list is the hand-off, and it is the right one: the two sibling notices carry
Verified after all fixes: 148 gate tests, 13 gate PASS in both memory modes, 254 crew tests, 149 repo tests, brand and scrub clean. |
a70035c to
6dae9ed
Compare
daa5a89 to
f49e8ae
Compare
f49e8ae to
6ad80d7
Compare
6ad80d7 to
280de9d
Compare
280de9d to
ddf8bd5
Compare
Lists the Kiro Crew gateways the owner deployed into their own AWS account as a service their customers reach: one CloudFormation stack per crew, one ECS service inside it. The pane is read-only. It creates nothing. Also brings the deploy machinery that produces those crews into the tree: the container image source, the two CloudFormation templates, the bundle curator, and the deploy driver with its 218 gate tests. WHAT THIS DOES NOT DO The UI is read-only: two GET routes, three read-only AWS calls (cloudformation describe-stacks, ecs describe-services, sts get-caller-identity), and no mutating verb anywhere in backend/crews.py. The only control is Refresh. Deploying a crew is a CLI action; the pane reports what the driver left behind. No part of this has run end-to-end against a real AWS account -- not chatbot, not persistent. The 218 gate tests all run under --dry-run, and every AWS-touching helper takes a fixture branch before its real path. secret_digest() held a defect that aborted a first real deploy before the secret was created, and it survived every green gate run. That is the honest measure of what these gates cover. The screenshots and the walkthrough clip are fixture data. They show the pane rendering each state; they are not evidence that a crew runs. TWO MEMORY MODES, CHATBOT BY DEFAULT chatbot keeps a conversation only while the task serving it is alive. No bucket is configured, the backup sidecar does not run, and the task role carries no S3 statement at all. That is stronger than scoping the grant to the crew's own prefix: a permission that does not exist cannot be reached by a future code path, and it makes the mode auditable from the IAM role rather than from the container's environment. persistent restores the two authority files at boot and fetches one transcript per turn; it is opt-in because it carries the restore path and the S3 grant that chatbot does without. A task replacement loses every conversation in chatbot mode, and that is not rare: it happens on every deploy, every failed health check, and every platform update. The mode's own label says so rather than a footnote. A PROPERTY WITH NO GATE IS A CLAIM restore emits one machine-readable line per boot, declared as an interface rather than log prose. A deploy gate reads it and refuses seven ways, and the mode decides which claim is checked, because a gate that knew only one mode would refuse every deployment of the other. An absent line means restore never ran, which is not the same as restoring zero. transcripts_available exists because the obvious assertion is vacuous alone: a crew with no history would pass trivially. DEFECTS FOUND WHILE BUILDING THIS Each was reproduced before it was fixed, and each has a test that reddens when the fix is reverted. The account binding is asserted, not assumed. A profile is a name a child CLI process resolves, so a profile repointed from account A to B would let a request for A's crews report B's. That is a disclosure, not an error, so it answers 409 account_mismatch with its own copy rather than a generic error wall. A boot loop, not a no-op. run_sidecar logs and returns when no bucket is configured, and the supervisor treats any child exiting as the end of the task, so an unconditional sidecar meant the container would come up, lose its sidecar, shut down, and be replaced forever. The supervisor tests ran with backup_bucket=None and asserted the sidecar starts, so the suite had encoded the loop as correct. One punctuation mark defeated the isolation. The front proxies a turn and the backend decides whether an id is legal, so the fetch ran first. dashboard:cust-1 is not a legal id, but the fold turns it into dashboard_cust-1, which names a real conversation: the old code downloaded that transcript and the backend then rejected the turn, leaving the task holding a conversation it never served. A health nobody measured. healthy was desired > 0 and running == desired, and the list route never calls ECS, so every crew came back False. That reads as "they are all down" when it means "nobody looked". It is None on a list now, and None for a crew scaled to zero, because False there blames the crew for its owner's decision. The seam guard's reverse direction had never run. It scanned for the long-form CloudFormation syntax while the driver uses the short form exclusively, so the pattern matched zero times and its pass was printed without being tested. Repairing it found a real mismatch: the driver passed a parameter crew.yaml deliberately does not declare and says so in a comment. PACKAGING AND SCOPE package_data globs builtin assets by fixed subdirectory name, so a new directory ships nothing while working perfectly from a source checkout. One glob is added for crew/**, with a test that fails when the glob is removed. The container runtime is excluded from the cross-platform gate by path rather than by the per-PR label, because it is the source of a Linux image rather than part of the app that installs on a user's machine, and a label would have to be re-applied on every future change to it. NOT DONE YET, DELIBERATELY Neither memory mode has run on a real AWS account. open_slots.json shrinks in persistent mode and must be resolved before that mode is used. There is no deploy from the UI: the pane reads, and wiring the driver to a route needs its own gated service because a crew is billable. Co-authored-by: Kiro Crew <noreply@kiro.dev>
|
/ai-review override gpt ecc0286: 14 of the 15 |
Human judgment recorded@chenmingwei23 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for
This decision applies only to this commit. A new push requires a new judgment. |
iamwhatever
left a comment
There was a problem hiding this comment.
Too many changes in one PR, need a close look
What this adds
A Crews pane in AWS Control that lists the KiroCrew gateways you deployed into
your own AWS account as a service your customers reach: one CloudFormation stack per
crew, one ECS service inside it, behind the load balancer a shared base stack owns.
The pane is read-only. It creates nothing.
It also brings the deploy machinery that produces those crews into the tree: the
container image source, the two CloudFormation templates, the bundle curator, and
the deploy driver with its 218 gate tests.
Naming: the Agents page's user-facing copy says "agents", and only its code calls
them crews (
CrewCard). These are remote, so the rail says Crews and the panetitle says Remote crews, with a blurb saying they run in the cloud on your bill
rather than on this machine.
What this does NOT do
Worth stating plainly, because the pane looks like a console and a console usually acts.
The UI is read-only. The backend adds exactly two routes, both
GET, and between themthey issue three AWS calls:
cloudformation describe-stacks,ecs describe-services, andsts get-caller-identity. No mutating verb appears anywhere inbackend/crews.py. Theonly control in the pane is Refresh. There is no deploy button, no delete, no test
connection, and no way to talk to a crew from here.
Deploying is a CLI action, not a UI one.
crew/scripts/smc-deploy.shis what creates acrew; the pane only reports what that script left behind.
No part of this has run end-to-end against a real AWS account. Not chatbot, not
persistent. The 218 gate tests all run under
--dry-run, and every AWS-touching helper inthe driver takes a fixture branch before its real path --
secret_digest()opens withif [ "$DRY_RUN" -eq 1 ]; then ... return 0; fi, and that same function held a defect(documented below) that aborted a first real deploy before the secret was created. A bug
on the real path survived every green gate run, which is the honest measure of what these
gates cover.
The screenshots and the walkthrough clip are fixture data. The five crews in them
(
billing-help,checkout-bot,winter-promo, ...) are invented bywebsite/scripts/lib/aws-control-crews-fixtures.mjs. They demonstrate that the pane renderseach state correctly. They are not evidence that a crew runs.
What a reviewer should therefore take from this pull request: the listing surface is
complete and tested, the deploy machinery is written and gated but unexercised against real
AWS, and a first real deploy is the outstanding validation.
Two memory modes, chatbot by default
Memoryis a crew-stack parameter with two values.chatbot keeps a conversation only while the task serving it is alive. No bucket
is configured, the backup sidecar does not run, and the task role carries no S3
statement at all. That last part is stronger than scoping the grant to the crew's
own prefix: a permission that does not exist cannot be reached by a future code
path, and it makes the mode auditable from the IAM role rather than from the
container's environment.
persistent restores the two authority files at boot and fetches one transcript
per turn. It is opt-in, not the thing you get by forgetting a flag, because it
carries the restore path and the S3 grant that chatbot does without.
A task replacement loses every conversation in chatbot mode, and that is not a rare
event: it happens on every deploy, every failed health check, and every platform
update. The UI says so in the mode's own label rather than in a footnote.
A property with no gate is a claim
restoreemits one machine-readable line per boot, declared as an interface ratherthan log prose (
crew/runtime/container/backup/restore.py,SUMMARY_TOKEN). Adeploy gate reads it and refuses seven ways, and the mode decides which claim is
checked, because a gate that knew only one mode would refuse every deployment of the
other:
transcripts_restored > 0state != disabledMemoryandSMC_BACKUP_BUCKETdisagreeavailable > 0state != okpartialmeans an authority file was missing, so resume is brokenavailable == 0transcripts_availableexists because the obvious assertion is vacuous alone: a crewwith no history would pass trivially. The counter is incremented at the write site
from
layout.is_transcript, never hardcoded, so reintroducing a bulk restore movesthe number instead of leaving a constant for the gate to read.
Where things live, and one packaging trap
setup.cfg [options.package_data]globs builtin assets by fixed subdirectoryname:
ui/,lib/,backend/,agents/,inject/,skills/,scripts/,*.md. A new directory is not shipped, so everything works from a source checkoutand every file is missing on every pip and DMG install, with no error anywhere. This
is the accident the
apple_speech/*.swiftcomment in that file describes. One globis added for
crew/**, with a test that fails when the glob is removed, and the sameentry in
MANIFEST.infor the sdist half.The deploy driver ships as a shell script rather than being ported to python.
Precedent:
deploy/skills/artifact-deploy/scripts/*.shis a whole set of shippedshell deploy scripts. Porting 2,000 lines would have discarded 218 gate tests.
Those gates run the driver under
--dry-run, which is what they can prove and alsotheir limit: every AWS-touching helper takes a fixture branch first, so the gates
exercise the driver's logic and never its real path. An earlier draft of this
description said the driver was "proven against a real account". That was wrong and
is corrected below.
Defects found while building this
Each was reproduced before it was fixed, and each has a test that reddens when the
fix is reverted.
The account binding is asserted, not assumed. A profile is a name a child CLI
process resolves, so a profile repointed from account A to B would let a request for
A's crews report B's. That is a disclosure, not an error, so it answers 409
account_mismatchwith its own copy rather than a generic error wall. Every listingre-derives the account through the same profile first. This is the posture
storage.find_driveestablished for writes, applied to a read.A boot loop, not a no-op.
run_sidecarlogs and returns when no bucket isconfigured, and
_wait_for_shutdowntreats any child exiting as the end of the task.An unconditional sidecar therefore meant the container would come up, lose its
sidecar within a second, shut down, and be replaced forever. Each piece behaved as
designed and nothing in the deploy would have said why. The supervisor tests ran with
backup_bucket=Noneand asserted the sidecar starts, so the suite had encoded theloop as correct.
One punctuation mark defeated the isolation. The front proxies a turn and the
backend decides whether an id is legal, so the fetch ran first.
id="dashboard:cust-1"is not a legal backend id, but the fold turns it into
dashboard_cust-1, which namesa real conversation: the old code downloaded that transcript and the backend then
rejected the turn, leaving the task holding a conversation it never served. The guard
is a shape test rather than a copy of the backend's grammar, which lives in a
dependency this process does not import.
The reader re-derived the writer's S3 key. The fetch had its own join of
backup_prefixandcrew_name, identical tolayout.object_prefix. It agreed andwas free to stop agreeing, and drift there is invisible: the fetch misses and a
returning customer is indistinguishable from a new one.
A health nobody measured.
healthywasdesired > 0 and running == desired, andthe list route never calls ECS, so every crew in a list came back
False. That readsas "they are all down" when it means "nobody looked". It is now
Noneon a list, andalso
Nonefor a crew scaled to zero, becauseFalsethere blames the crew for adecision its owner made.
The seam guard's reverse direction had never run. It scanned for
ParameterKey=,the long-form CloudFormation syntax, while the driver uses the short
Name=Valueform exclusively: the pattern matched zero times, so "driver passes nothing the
template fails to declare" was printed without ever having been tested. Repairing it
immediately found a real mismatch invisible until now: the driver passed
RestApiRootResourceIdto the crew stack, whichcrew.yamldeliberately does notdeclare and says so in a comment.
Two mode labels that were not one switch. "Keeps memory" opposite "Chatbot": one
states a behaviour, the other names a category, and the category says nothing about
memory, which is the whole distinction. Thirteen locales carried it.
Tested
on the raw id, ignoring the mode in the gate, granting S3 in chatbot mode,
re-deriving the S3 key, dropping the account assertion, unanchoring the stack
regex, starting the sidecar unconditionally, and removing the package_data glob
each redden a named test
(which reports every layout box as zero). The harness also fails when no row mixed
a badged card with a bare one, so it cannot pass vacuously
Not done yet, deliberately
Neither memory mode has run on a real AWS account. Every number above is offline.
open_slots.jsonshrinks in persistent mode. Boot restores it, every transcriptit names is absent by design, and the backend treats an absent transcript as a
confident answer rather than an unreadable one, so the key is dropped from the next
flush and the sidecar uploads the reduced file. In chatbot mode this cannot happen,
because nothing is uploaded. It must be resolved before persistent mode is used, and
the choice is real: either the file is authoritative and this is data loss, or it is
not and the restore requirement should go.
No deploy from the UI. The pane reads. The driver is a shell script an owner runs.
Wiring it to a route needs its own gated service, because
GATED_SERVICESexistsahead of the first billable call and a crew is emphatically billable: a Fargate task
runs until it is stopped, behind an ALB, egressing through a NAT.
The conversation reader is a named stub.
crew/conversations.pywill readtranscripts out of S3 for the owner's view. Chatbot is the default and has no S3
conversations, so shipping it now would be a reader for something no default
deployment produces.
What it looks like
Captured against fixtured routes in an isolated instance, no AWS credentials. The
harness is
website/scripts/capture-aws-control-crews.mjsand it asserts as it goesrather than only saving files: it fails when two cards in a row put their fact grids
at different offsets, when no row mixed a badged card with a bare one (so it cannot
pass vacuously), and when any fact value is clipped to an ellipsis.
Two of these are empty states, and the easy mistake is to render both as "no crews".
The pane. Crews sits in the rail beside Files / Library / Backup / Access.
winter-promois DELETE_IN_PROGRESS and is listed with a red border rather thanfiltered out, because hiding a half-deleted crew is how it becomes a surprise on the
next bill.
legacy-triageshows MODE Unknown rather than a mode it guessed: itsstack predates the parameter, and the backend returns an empty string so the UI can
say it does not know.
One crew opened. Stack, mode, service with running/desired, endpoint, the full
image digest, region. The URL stays
/aws-control/crews, so this is view state witha breadcrumb rather than a route, matching the drive.
No base stack, so no crew can exist yet. It says what is missing rather than
reporting an empty list.
The base is ready and the account holds none. Distinct copy from the frame above.
account_mismatch. The profile now signs in to a different AWS account, so listing
that account's crews would be a disclosure. Its own warning, not a generic error wall.
320px. One column, no card overflowing the viewport.
Walking the pane, one continuous take: click Crews, read the grid, open a crew,
return by the breadcrumb.
Walkthrough
A 19s silent clip of the pane, recorded against the real built SPA with every
/api/**call answered from fixtures, so it needs no gateway and no token and reproduces from a
clean checkout:
Same clip as mp4 (0.83 MB), if the GIF is too coarse to
read the digests.
It walks the grid, one crew opened, the detail Refresh, and then the three states that
are deliberately NOT one screen: base not deployed, base ready with no crews, and
account_mismatch. The mismatch banner is at ~13.5s. It ends at 320px wide.vite buildis invoked directly rather than throughnpm run build, which istsc -b && vite build:tsc -bfails onsrc/stories/**when the Storybook devdependency is not installed, and because of the
&&that means vite never runs and therecording is made against a STALE
dist. That happened on the first take. The storiesare leaves outside the SPA entry graph, so skipping the typecheck does not change the
bundle.
What the agent can reach under the unsandboxed default, measured
A reviewer flags
build_backend_env's wholesaledict(os.environ)copy as puttingcredentials in front of the agent. The copy is real and two credentials travel in it.
Neither is closed by stripping, and the consequence of each is bounded -- both facts
measured rather than argued.
KIRO_API_KEYis the model credential. The backend cannot answer a turn without it, sounder
AllowUnsandboxedExec=truea prompt can read its own credential. There is noversion of this that keeps the model working and hides the key from it; that is the
sandbox decision, not a separate defect.
AWS_CONTAINER_CREDENTIALS_RELATIVE_URIis injected by Fargate and is NOT stripped, so ashell can fetch the task role's temporary credentials. What that role can do:
crew.yaml's TaskRole isPolicies: !If [PersistentMemory, [own-prefix-only ...], !Ref 'AWS::NoValue'], so inchatbot mode there is no policy at all. The driver's gate 7 asserts
state=disabledandgate 13 proves the role carries no S3 action.
s3:GetObject/s3:PutObjectoncrews/<crew>/*and aprefix-scoped
s3:ListBucket. Nos3:DeleteObject. That is the crew's own transcripts,which the container already reads and writes through its backup sidecar, so the
credential grants no reach the crew did not already have.
Stripping the variable would be defence in depth and is deliberately not presented as a
fix, for the reason measured on the control secret above: everything in the image runs as
one user (
USER crew), so a sibling process's/proc/<pid>/environstill carries it, andneither
del os.environ[...]noros.unsetenv()scrubs that file.The two real closes are the sandbox -- which on Fargate means the container refuses to
start, so
--require-sandboxexists as the owner's opt-in -- and privilege separationinside the image. Both are owner-facing decisions rather than defects, which is why they
are here in the description instead of being quietly changed.
The control secret IS readable by the agent, and that buys a 404
A reviewer raised this on
crew.yaml'sSecretsblock, and the exposure half iscorrect.
SMC_CONTROL_SECRETis injected as a task-definition secret, the image runseverything as one user (
USER crewin the Dockerfile, no privilege separation betweensupervisor, front and backend), and on Fargate the model runs unsandboxed with an
auto-approved shell. So a prompt can read another process's
/proc/<pid>/environandrecover it.
An earlier round already stripped it from the environment the backend hands the model
subprocess. That closed direct inheritance and nothing more: a sibling process owned by
the same user still has it.
Unsetting it after load is NOT the fix, measured rather than assumed. On Linux
/proc/<pid>/environreads the block placed there at exec, and neitherdel os.environ[...]noros.unsetenv()scrubs it:So an unset would read like a fix while changing nothing an attacker sees. It is not
being added for that reason.
What recovering the secret actually grants: a 404. The front's gateway route sends
everything that is not the single customer turn path through the control gate, and the
authorized branch returns
control_not_implementedwith status 404. The container frontimplements no control operation at all -- the owner's control plane is off-box. The real
boundary is API Gateway
AWS_IAMplus an internal-scheme ALB, which is why the contractprovides no
ControlSecretValueand treats the header as a non-boundary in the firstplace.
Closing it properly means privilege separation inside the image: run the front under a
different uid from the backend, so
/proc/<front>/environis not readable by the agent.That is a Dockerfile and supervisor change, it is the right eventual shape, and it is not
in this pull request -- deliberately, because the thing it would protect currently answers
404.
The multi-tenancy boundary, and why it is a declaration rather than a mechanism
Reviewers raised this six times across seven rounds, from three different angles, and every one of them was right about the same thing: inside a persistent-memory crew there is no credential-enforced boundary between one customer and another. This section is the disposition, because the honest answer needed a decision rather than a patch.
What the isolation actually isolates. The task role is scoped to
crews/<crew>/*. That separates one CREW from another, and the deploy gate proves it against lookalike prefixes likecrews/<crew>2/andcrews/<crew>-evil/. It does not separate one CUSTOMER from another: every conversation the crew has ever served lives under that one prefix.Three facts compose into the exposure. The prefix is shared. The container runs its backend with every tool auto-approved, because an unattended crew that stalls waiting for an approval nobody will see is a crew that answers nothing. And the conversation id arrives in the request rather than being derived from the caller. So a turn driven by one customer can name another customer's conversation.
What was fixed, and it is narrowing rather than closing.
s3:DeleteObjectis gone from the role. Nothing in the container could ever call it: the object store exposesput,getandlistand no more, in all three implementations, and that is deliberate, because the restore path SKIPS objects it must not hold rather than removing them. In a backend that auto-approves every tool, a permission that exists is a permission a prompt can reach, so an ungranted destructive action is the difference between a customer-driven turn reading a transcript and erasing one. The isolation gate now asserts the absence in both modes, and that assertion was verified by granting delete back and watching it reportallowed (expected denied).What is now required rather than assumed.
Memory=persistentrefuses unless the owner also setsTrustDomain=single-principal. The refusal exists twice on purpose: a CloudFormationRule, which a console deploy or a pipeline cannot skip, and a parse-time check in the driver, so the owner learns before step 0 instead of watching six steps of work reach CreateChangeSet. Setting it is the owner stating that every caller they authorise is one tenant, or that their own authentication layer scopes ids in front of the crew. Leaving it empty refuses the deployment rather than shipping on an assumption nobody made out loud.Memory=chatbot, the default, is unaffected: it grants no S3 action at all, so there is no shared prefix to reason about.Why binding ids to the caller is not in this pull request. It is the right eventual fix and it is deliberately deferred, on an asymmetry: a refusal that is wrong fails closed, and a deployment does not happen. Identity binding that is wrong fails open, and one customer receives another's history.
This branch has direct evidence of how easily the second happens. A credential fence added earlier in this same review cycle was changed to judge the resolved target for two of its three checks and not the third, so a symlink to
/proc/self/environwalked past all three and the deploy process's environment would have been inlined into a shipped prompt. That was written with the security intent explicit, on a branch already six review rounds deep. Adding a new mechanism in the customer data path at the end of a long review cycle, for a mode that has never run on a real AWS account, is the same conditions with a worse blast radius.There is also nothing to bind to yet.
AWS_IAMguards the CONTROL plane (base.yaml), the ALB isScheme: internal, and the front reads no caller identity at all today, only the control secret header. Identity binding is a new path through the request, not a line to add, and which identity it should read depends on the authentication the owner puts in front, which this design leaves to them.Why the reviewer's second suggested fix does not work on this integration. The
finding offers two remedies: apply the single-principal requirement to chatbot mode too,
or namespace ids with verified caller identity. The second one looked cheaper, so it was
costed first, and it does not hold here.
Namespacing needs the caller's verified ARN to reach the container. The only place that
identity exists is API Gateway, and the crew method is
Type: HTTP_PROXYover aVPC_LINK(crew.yaml). Injectingcontext.identity.userArnas a request header on aproxy integration is not a boundary: proxy integrations forward client headers as they
arrive, and AWS documents parameter mappings as the capability of the CUSTOM (non-proxy)
integration -- "With the HTTP custom integration, also known as the non-proxy
integration, you have more control of which data to pass ... You do this using data
mappings." There is a reported case of a client value and an integration-configured
value both arriving at the backend rather than the latter replacing the former.
If that is the behaviour for headers too, a caller sends the header itself and chooses
whose conversation to resume, which is worse than the honest declaration this branch
ships today. Confirming the override semantics needs a live deploy of this exact
integration type, which has not been done, so the mechanism is not being built on an
assumption.
That leaves the first remedy, a resource policy on
ControlApipinningaws:PrincipalArn. It is IAM-enforced and depends on no header semantics.ControlApiisAWS::ApiGateway::RestApiwith no resource policy today, so it isimplementable. It is not in this pull request because it has a consequence the owner
should choose rather than inherit: pinning the API to one principal locks out every
other principal in the account, including the common split where a pipeline deploys the
crew and a person invokes it. Defaulting it to the deploying principal would make that
lockout the default behaviour of the primary path.
Two alternatives considered and rejected, with reasons.
A sandbox would genuinely close it: the sandbox makes agentic command networking unreachable, so a shell could not reach the ECS credential endpoint and would hold no credentials to use. It does not apply here, and the code already says why --
Fargate does not permit an unprivileged user namespace. The driver therefore defaultsAllowUnsandboxedExec=true, and--require-sandboxturns that into a refusal to boot rather than a sandbox. That default is a platform constraint, not an oversight, and it is stated here because until now it lived only in a comment.A safety policy denying cross-conversation reads raises the cost of an accidental leak and is worth having, but it is not a boundary. The tool being policed is
execute_bashwith an arbitrary command, and pattern-matching that is a losing game:aws s3 cp,python -c, acurlat the credential endpoint, the same command base64-encoded. Something a prompt can talk its way around is not an isolation boundary, and describing it as one would be worse than the gap.Rebutted: the crew GET routes do not audit a successful read
A review round asked for
_auditon every outcome of both crew GET handlers, on thegrounds that a successful owner read records no permission decision. The routes follow
this file's own convention exactly, and changing only these two would make it
inconsistent in the other direction.
routes.pyhas two wrappers._mutating(operation)auditssuccess/refused/erroraround a handler and is used 16 times, all mutations._guarded(...)auditsDENIALS -- app disabled, restricted session -- and errors, and does not audit a
successful read. Both crew routes are registered with
_guarded, so a denial and anerror are already recorded; the finding is about the success path only.
Counted rather than asserted: of the 15
_guardedGET handlers in the file, 14 have zero_auditcalls in their bodies.The single exception says where the line is.
_handle_drive_downloadaudits because itMINTS a presigned URL, and its own comment gives the reason: "Same decision class as a
share: the presigned URL works for anyone". What is recorded there is that a bearer
credential was granted, not that a read happened -- which is also why that one call is
asyncio.shielded, so the grant record survives a client disconnect.The crew routes mint nothing. They make three read-only AWS calls
(
cloudformation describe-stacks,ecs describe-services,sts get-caller-identity)and return what they found. No mutating verb appears anywhere in
backend/crews.py.If successful reads should be audited, that is a repo-wide change across 14 existing
routes and belongs in its own pull request, where the volume it adds to the SEL log can
be weighed once rather than argued per route. Doing it for two routes here would leave
the file with one convention for crews and another for everything else, which is the
drift this branch has already paid for several times.