Skip to content

feat: AWS Control - account portal + S3-backed cloud drive - #5517

Merged
bolichen97 merged 1 commit into
mainfrom
feat/aws-control
Aug 27, 2026
Merged

feat: AWS Control - account portal + S3-backed cloud drive#5517
bolichen97 merged 1 commit into
mainfrom
feat/aws-control

Conversation

@chenmingwei23

@chenmingwei23 chenmingwei23 commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

What is the problem?

KiroCrew touches the user's AWS accounts from several disconnected corners -- deploy-web publishes sites, voice features bill Polly/Transcribe behind consent, agents run read-only CLI calls -- but there is no single surface that answers: which accounts can KiroCrew use, do their credentials still work, what has KiroCrew created in them, and what does that cost this month. And the gateway's own data (artifacts, sessions, memory, workspace) has no durable cloud home: no backup, and no way to hand a file to someone who does not run KiroCrew.

Why this issue matters to the user

  • A stale SSO session today surfaces as a scattered failure (a deploy error, a silent voice refusal) instead of one health light with one fix action.
  • 27 GB of cold sessions on one machine is a single disk failure away from gone, while the same bytes in S3 cost about $0.62/month (about $0.11 in Glacier IR).
  • Sharing anything currently means leaving the product entirely.
  • Every future AWS capability would re-invent account selection, consent, confirmation, and cost display -- this PR builds those once, as one app.

How our fix solves it

One builtin app, aws-control, spec at docs/system-specs/features/aws-control.md (two-page design signed off before implementation):

  • Accounts page: the deploy profile registry (names-only, unchanged format, deploy-web untouched) is aggregated by the AWS account each profile resolves to via the same free STS probe the consent surface uses. One health light per account; a degraded account converges to a single Reconnect action whose guidance matches how the profile authenticates (sso / credential-process / other -- classified through aws configure get reads, never by touching credential files).
  • Storage engine: one private bucket per account (kirocrew-drive-<12hex>, BPA + SSE + BucketOwnerEnforced via the deploy engine's own hardening, PLUS versioning -- the drive's deliberate delta from deploy-web), discovered stateless-by-tag with ambiguity failing loud. Three prefixes serve three console sections: artifacts/ (Library), drive/ (Drive), backup/ (Backup). All AWS access stays behind the deploy engine's single run_aws CLI chokepoint, gateway-side.
  • Account Console: overview stats; Bill from Cost Explorer (one query per account per day, cached, stale-served with its age labelled; projection computed locally -- no AWS Budgets resource is ever created); Library (artifact push with a sync ledger); Drive (browse/upload/download/delete); share via presigned links with a metadata-only local ledger (the URL is returned once and never persisted -- it IS the grant); Backup (memory/workspace snapshot + whole-set sessions archive honouring the both-halves-together invariant, nightly loop, restore lands in a staging folder -- never a hot-swap under a running gateway).
  • Guards: every endpoint is dashboard-owner-only including reads (account ids and ARNs are what the keystone-fenced consent leaf is fenced from); the paid services this app uses (s3, ce) join the existing aws-usage-consent enum -- grants stay in the one keystone leaf, re-verified against a live identity probe, failing closed; bucket creation is a two-call confirm (preview, then explicit confirm); every mutation refuses restricted sessions and is SEL-audited. A new self-contained drive IAM tier renders the least-privilege policy pinned to kirocrew-drive-* for the user to apply (the gateway never writes IAM).
  • Surface language: zero AWS jargon at the top layer; each section carries an under-the-hood drawer showing the real bucket and the equivalent CLI line. Tasks and Sites render as ghost cards ("connects later") exactly as the approved design shows them -- scheduled crawlers, publish consolidation, recipient-account share tiers, and per-account multi-grant consent are future work (tracked in the spec's open questions).

What tests we did

  • 44 new backend tests (test/test_aws_control_app.py): route inventory as a table (a route added without a gate fails the inventory), disabled/non-owner refusal across every route, restricted-session refusal across every mutation, consent-refused-before-any-AWS-call, two-call confirm (preview creates nothing), hostile object-key refusal, presign expiry clamp, tag-discovery ambiguity + foreign-naming refusal, share ledger never storing URLs, costs cache freshness + stale-serve-on-missing-consent, backup kind/key validation, nightly due logic, and the drive IAM tier's scoping (no deploy-web statements leak in either direction).
  • Adjacent regression suites green: 92 aws-consent, 204 builtin discovery/manifest/lifecycle, 124 IAM policy-shape, plus isort/flake8/black/mypy on every touched backend file.
  • Frontend: vitest for the app + the full i18n suite (636 tests -- catalog parity across 13 locales, key references, dead keys, context sidecar, locale style gates), tsc --noEmit, eslint with zero new warnings, check-app-manifest-sync (manifest prose byte-identical to the catalog), lint:i18n, and the full i18n:check runner. Locale style findings (Hindi formal-pronoun register, Japanese long-vowel marks) were fixed at the source, not by moving baselines.
  • Pod e2e verification screenshots follow as a PR update.

Any other suggestions on the work

  • This is a deliberately large single PR (maintainer's call, replacing a 7-phase plan). If a review lane hits its judgment cap, the section boundaries above are the natural review chunks.
  • Presigned links signed with temporary credentials (SSO/assumed-role) can die before their requested expiry when the session credential expires; the UI copy says so instead of promising the full window.
  • The consent store holds one grant per service, so paid features follow the registry-default account in this PR; the per-account multi-grant extension is recorded in the spec as the next structural step.

Closes #5496

Screenshots (pod e2e, SYNTHETIC staged data -- no real account identifiers)

Accounts page after the design rework -- dense ~40px rows, each leading with the account name and the FULL 12-digit account id, a single quiet aggregate line plus client-side search instead of the old stat-card strip. All API responses are staged with synthetic identifiers (the real-credential e2e run is described in the Tests section; its screenshots are not published):

Accounts

Account detail page -- grouped General card (name, full id with copy, region, connection, keys) and a Connections section with one row per key (kind badge, region, health). Reconnect now lives here for failing keys. Below: settled stats (em dash with a hint where the test account has Cost Explorer disabled -- the 502 settles instead of skeletoning), the S3 setup confirmation card, ghost cards:

Console

An UNRESOLVED account row cannot open a console, so clicking it expands inline Reconnect guidance instead of being a dead row (command classified gateway-side, display-only with copy):

Row reconnect

@chenmingwei23
chenmingwei23 requested a review from a team August 24, 2026 06:49
@chenmingwei23
chenmingwei23 requested a review from a team as a code owner August 24, 2026 06:49
@chenmingwei23
chenmingwei23 requested a review from pepmach August 24, 2026 06:49
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 24, 2026
@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 24, 2026
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of 4c2cf69107b23020e5a256ffbb9eddefcc249598 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 4c2cf69

False positive or not applicable? A repository writer can comment:
/ai-review override gpt 4c2cf69107b23020e5a256ffbb9eddefcc249598: <one-sentence reason>

@bolichen97
bolichen97 enabled auto-merge (squash) August 24, 2026 06:55
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 24, 2026
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of 4c2cf69107b23020e5a256ffbb9eddefcc249598 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

I have enough for the verdict. The design is thorough — spec-first, reuses the deploy engine chokepoint, consent fails closed, two-call confirm, owner-pinned transfers. One genuine design gap survives the kill-filter: the storage-lifecycle story.

Design-Verdict: CONCERNS

Sound, spec-first design — but it creates an unboundedly growing billable resource with no reclamation path or retention, in an app selling cost transparency.

Watch

  • No storage-reclamation story. The nightly loop pushes a full timestamped snapshot daily (key = f"snapshots/kirocrew-snapshot-{_stamp()}.tar.gz", no remote retention — only the local --keep 1), the bucket is versioned, and the only delete surface writes a delete marker ("'deleted' is recoverable at the S3 layer until a purge exists"). So backup storage grows monotonically unattended, nothing the user deletes ever frees bytes, and there is no teardown in this PR — cause → the bill climbs while usage() (current-versions-only list-objects-v2) reports shrinking usage after deletes → the app's own "space used + estimated cost" number diverges from the Bill card it sits next to, and a non-technical user's only exit is hand-emptying a versioned bucket in the raw AWS console, which the product's whole premise says they can't do. The spec acknowledges only a future "version-aware purge" for destroy, not daily-accumulation retention.

Suggestions

  • Put an S3 lifecycle rule on the bucket at create_drive (noncurrent-version expiry + backup/snapshots/ age-out): one idempotent put at bootstrap, keeps discovery stateless, and closes both the delete-marker leak and nightly accumulation without building the purge.

[DESIGN-REVIEWED] 4c2cf69

@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — 🟡 CONCERNS

UX-level review of 4c2cf69107b23020e5a256ffbb9eddefcc249598 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

UX-Verdict: CONCERNS

Most console actions fail in silence — upload, push, backup, restore, and share creation render no error state, on a surface whose whole premise is credentials that expire.

Watch

  • Silent mutation failures. uploadMut, pushMut, runMut, nightlyMut, restoreMut, shareMut, and forgetMut in ConsoleView.tsx never render isError; only Delete and Register do (both have good strings like delete_failed). Worst case: the restore caveat itself predicts "it will be denied unless the account's credentials are broader," yet the denial shows nothing — button re-enables, user retries blindly or believes "Back up now" ran. High frequency (AWS auth failure is this app's normal case) × task-failure impact × every occurrence. Fix: reuse the existing inline delete_failed pattern per mutation; also flip the nightly toggle optimistically or show pending — it currently doesn't move until the refetch returns.
  • Hand-rolled layers lose Escape and focus. ShareDialog is a bare fixed inset-0 div (X button only) and the two overflow menus (drive-more-menu, drive-crumb-menu) are absolute divs — no Escape, no outside-click dismiss, no focus trap/return, while ui/dialog.tsx and ui/dropdown-menu.tsx ship all of it. A keyboard user opens Share, Escape does nothing, and an open row menu stays pinned over content. Fix: swap to the Radix primitives.
  • One object, three names. The same profile is "keys" (rows, aggregate, General), "profiles" (Add accounts, cap message), and "Connections" (section header) on one page — a newcomer can't tell whether registering a profile adds a key. Pick one term; keep "profile" only for the literal ~/.aws name.
  • Empty state points away from its own fix. empty_body says "Profiles are added under Settings → Deploy for now" while the collapsed "Add accounts" section directly below registers local profiles in place — a first-run user takes a cross-app trip for nothing. Point the empty state at Add accounts and auto-expand it when the list is empty.

Suggestions

  • library_push "Push" → "Sync to drive": names the destination and matches the tile's own "synced / not synced" status vocabulary.
  • backup_restored_note "Nothing was hot-swapped." → "Your current data was not replaced." — mechanism jargon in a plain-language surface.

[UX-REVIEWED] 4c2cf69

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 24, 2026
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of 4c2cf69107b23020e5a256ffbb9eddefcc249598 — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All evidence is gathered; the counts I needed are verified (existing deploy profile control plane at deploy/handlers.py:2331-2332, the shared discover_aws_profiles, the make_entry region substitution, the repo's temp-screenshots/ convention, and the ghost cards in ConsoleView.tsx). Final review:

First-Principles-Verdict: CONCERNS

The portal quietly ships a second profile-registration surface the description never mentions, duplicating the deploy control plane that already lists and registers the same registry.

What this change ships

Intent: give users one surface to see/repair their AWS accounts and a consented S3 drive/library/backup — an ADDITION.

  1. New AWS Control app: accounts list with health lights + search — justified
  2. Per-profile Reconnect guidance (sso / credential-process / other) — justified
  3. Pick and register local AWS profiles from the portal — undeclared; duplicate of /api/deploy/profiles (deploy/handlers.py:2331-2332)
  4. S3 drive: two-call bucket confirm, browse/upload/download/delete — justified
  5. Presigned share links + metadata-only Access ledger — justified
  6. Bill page from Cost Explorer, cached daily — justified
  7. Cloud artifact Library push — justified
  8. Backups: manual + nightly loop, staging-only restore — justified
  9. s3/ce join the consent enum; app data dir agent-fenced — justified (named boundaries)
  10. Tasks/Sites ghost cards + an all-null per-account summary wire block — speculative placeholders
    More than 10: ~450 lines of deploy/iam.py black-reformat + baseline prune, the engine._trimmed_stderr change to deploy's own error text, and load_registry shape-tolerance all ride along undeclared.

Watch

  • Duplication (counted): GET /profiles/available + POST /profiles/register re-spell _handle_profiles_get/_handle_profiles_post (deploy/handlers.py:1526/1543) — same discover_aws_profiles, same locked_registry/make_entry, same 50 cap. Two registration paths into one file will diverge; they already do (next item). The spec's own §5 API list, added in this commit, omits both routes.
  • Point patch, 1 unfixed sibling: the new register path reads each profile's declared region because "make_entry substitute[s] DEFAULT_REGION … the drive bucket then gets created in the WRONG region" (routes.py comment) — but deploy's _handle_profiles_post still writes make_entry(name, region-or-DEFAULT) (profiles.py:152) into the same registry the drive reads. The cause lives in make_entry; fix it there.
  • Ghost cards' only provenance is "the approved design shows them" — inherited; their zero option costs no capability.
  • The iam.py reformat lands in the feature commit; AGENTS.md says a baseline-file format goes in its own commit.

Subtractions

  • Drop /profiles/available + /profiles/register — point the portal at the existing /api/deploy/profiles pair (deploy/handlers.py:2331-2332), carrying the region fix into make_entry.
  • Drop the summary block (accounts.py to_dict, AwsAccountSummary) — zero producers; every field is a hardcoded None this PR.
  • Drop _MAX_REGISTERED (routes.py) — the limit's owning module already defines _MAX_PROFILES = 50 (profiles.py:77).

[FIRST-PRINCIPLES-REVIEWED] 4c2cf69

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Aug 24, 2026
@github-actions

github-actions Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed 4c2cf69107b23020e5a256ffbb9eddefcc249598 — this comment is updated in place on each push.

Review details

Both candidates fail the falsification bar at requirement (a) — a concrete input that occurs in practice.

Candidate 1 (accounts.py:380): The claimed defect only manifests if a profile name or region matches a credential/URL redaction pattern. Profile names are user-chosen strings from the AWS profile charset (prod, default, company names) and regions are standard tokens (us-east-1); neither matches an AKIA/ASIA access-key shape, a base64 secret blob, or an exfiltration-URL token in practice. The scenario requires a pathologically-named profile ("could match"), and even then it fails closed (availability, no wrong-account write). Below 80.

Candidate 2 (costs.py:111): The float(group["Metrics"]["UnblendedCost"]["Amount"]) access is only unsafe against a CE response missing the metric it explicitly requested. _checked returns only on rc==0, json.loads succeeds, and the CE get-cost-and-usage --metrics UnblendedCost API contract guarantees each Groups[*].Metrics.UnblendedCost.Amount is present and numeric. A malformed successful response does not occur in practice — this is an AWS-owned output path, not attacker content. Below 80.

Neither survives; nothing in the diff neighboring these two files rises to the grounded bar on my own read.

No findings.

[OPUS-REVIEWED] 4c2cf69

Verdict parsed from the review's SHA-scoped output markers for commit 4c2cf69107b23020e5a256ffbb9eddefcc249598.

False positive or not applicable? A repository writer can comment:
/ai-review override fable 4c2cf69107b23020e5a256ffbb9eddefcc249598: <one-sentence reason>

@chenmingwei23 chenmingwei23 added the posix-only-approved Cross-Platform Portability findings reviewed and accepted as intentionally POSIX-only label Aug 24, 2026
@github-actions github-actions Bot removed the readiness: action required A blocking check or review needs attention label Aug 24, 2026
@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 20 disposition - head a538fe1

Two findings, plus two Windows test failures of my own making. One finding
fixed; the other is the third raise of a class that belongs to #5430 and will be
cleared by override on the head that carries it.

1. Backup authorization temporaries remain agent-writable - real, and the
remedy was right. Round 18 fenced the single name backup.json, which is not
enough: _write_state is a tmp-plus-rename, so the bytes that become
backup.json exist under a different name in the same directory first, and an
agent that could write that temporary could still land nightly=true. Fencing
one file in a directory whose writes go through a second file is a half fence.

The entry is now the whole DIRECTORY, apps/aws-control/data, which is the
shape the list already uses for exactly this reason - whatsapp is fenced as a
directory so the WAL and SHM sidecars carrying the same key bytes are covered
too, and trust, run and .vault for the same argument. This also revises a
claim in the round-18 disposition: the siblings (cost cache, library ledger) are
now fenced as well, and that is fine rather than collateral - none of them has a
legitimate file-tool reader, the backend opens all of them directly rather than
through this gate, and a state file added later is covered without a new entry.

Verified against the live classifier, not the list: is_sensitive_path and
is_sensitive_write_path both return True for backup.json, for
backup.json.tmp, for a lock beside it, and for the cost cache, under both crew
home prefixes - and False for another app's data directory, so the fence did not
widen past this app.

2. Parent-directory swaps escape backup and restore roots - real residual,
not fixable at this size, and the third raise of the class (round 19 named the
leaf, round 20 names the parent). Closing it means a traversal pinned on
directory HANDLES throughout - O_DIRECTORY opens plus dir_fd=-relative
openat/scandir for every level, on both the archive walk and the restore
write. That is a containment project, not a line change, and it is the same
project that was split out of #5195 into #5430 rather than fixed inline there.
The finding's own remedy is to revert session backup and restore, which trades a
working feature for a race that needs a local agent write, an exact window, and
a directory the owner has to be backing up at that moment.

What the code does have on that path: the walk refuses to descend into a
symlinked directory and prunes linked names, every leaf is opened once with
O_NOFOLLOW | O_NONBLOCK and read only through the descriptor, restore refuses
a linked staging dir and re-verifies that it resolves to exactly
<app data>/restore after the mkdir, and the download destination is an
O_EXCL temporary with an unpredictable name reached by os.replace. The
residual is the parent-swap window above that, and it is recorded here rather
than papered over. Override goes on the next head GPT reviews if the class comes
back, since pushing after an override voids it.

Windows Backend Tests (1) - two failures, both mine, and the first exposed a
real behaviour difference rather than just a bad assertion:

  • _O_NOFOLLOW degrades to 0 on Windows, so the descriptor-pinned open
    happily followed a symlink there and archived its target - the Linux-only
    guarantee was quietly weaker on Windows. The pre-open is_symlink() check is
    restored, so a link is refused on every platform; on POSIX O_NOFOLLOW
    remains the authoritative race-free refusal and the check is redundant, while
    on Windows it is the only guard (that platform offers no primitive to close
    the swap race, which is the same residual as finding 2).
  • The state-leaf assertion compared a WindowsPath string against a
    /-joined catalog key. Fixed by comparing as_posix(). The FIFO test now
    skips where os.mkfifo does not exist.

Gates: 73 aws-control backend tests, 42 security-posture tests, 637 security
tests, black / isort / flake8 / mypy clean. security.py carries one hunk and
no reformatting.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 21 disposition - head a58c838

Three findings, all real, all fixed. The descriptor-pinned-traversal class did
not come back - the reviewer falsified its own Windows candidate this round - so
the override the round-20 disposition reserved is not needed.

1. GovCloud drives are never rediscovered (BLOCKING) - real, and the worst
consequence in this round. tag:GetResources returns a partition-qualified ARN,
and an S3 bucket ARN's partition is not always aws: GovCloud is
arn:aws-us-gov:s3::: and China is arn:aws-cn:s3:::. Discovery matched on the
literal arn:aws:s3::: prefix, so on those partitions it dropped the bucket the
app had just created - the console then reports no drive at all, and the next
confirm mints a SECOND billable bucket holding none of the first one's data.

Discovery now anchors on :s3:::, which identifies the service and ignores the
partition, and still rejects any other service's ARN. The rendered drive policy
had the same defect in the other direction - arn:aws:s3:::kirocrew-drive-*
grants nothing in GovCloud or China, so an owner there would be denied on their
own drive - and its resources are now arn:*:s3:::kirocrew-drive-*. The
wildcard is on the partition field only; the bucket-name pattern stays exact, so
round 3's "full naming-scheme match, never a prefix" scoping and round 14's
backup-write-only split are both unchanged. Pin tests cover all three partitions,
a non-S3 ARN still being rejected, and no statement in the tier pinning the
commercial partition.

2. Download loses Safari user activation (FINDING) - real, fixed. awaiting
the presign and calling window.open afterwards spends the click's user
activation on the await, so Safari (and Chrome with popups restricted) blocks the
window and the Download button silently does nothing. The tab is now opened
synchronously inside the handler and navigated once the presign returns, with the
blank tab closed on failure so a rejected request leaves nothing behind.

3. Restore is denied under the rendered policy (FINDING) - third raise
(rounds 17, 19, 21), and rather than restate the disposition a fourth time the
gap it names is now closed. The disposition itself does not change: the
write-only backup grant is round 14's fix and stays the default, and removing
Restore would be wrong because the strict tier is opt-in and an ordinary SSO or
console profile restores fine. What was missing is that an owner who pasted the
strict tier met this as an AccessDenied instead of as a documented choice. The
stored-backups list now carries the caveat in place, in all 13 locales: restore
needs read access to the backup folder, the recommended least-privilege policy
grants write only on purpose, and it will be denied unless the account's
credentials are broader.

Two self-inflicted items worth recording. The Italian style gate caught piu
where the orthography requires the grave-accented form in the new copy - I had written the
Latin-script translations without diacritics to keep the publishing scrub happy,
which is the same mistake a UX round caught earlier in this PR. All five
Latin-script locales were rewritten with correct orthography for BOTH new keys,
not just the one the gate flagged. Separately, three existing drive-tier
assertions hardcoded arn:aws: and had to move with the policy; they now assert
partition neutrality explicitly, so a future partition pin fails the suite.

Gates: 78 aws-control backend tests, 42 security-posture tests, 748 tests across
the deploy/IAM/policy surfaces, black / isort / flake8 / mypy clean; tsc -b
clean, 659 frontend tests across the app and i18n trees, i18n:check 18/18
PASS, eslint 644 warnings against a 664 ceiling.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 22 disposition - head e54a154

Three findings. Two fixed. The third is the fourth raise of the
descriptor-pinned-traversal class and will be cleared by override on the head
that carries it.

1. Nightly uploads omit invocation outcome auditing - real, fixed, and the
cleanest finding of the round. Every owner-driven mutation in this app is audited
because the dashboard layer sits in front of it; the nightly loop has no request,
so the one operation that runs with nobody watching was the one with no trail.
The hook now emits the same three-part SEL record around the call - invoked
before, then succeeded with the object key, failed with the error, or
cancelled on teardown - best-effort by the same rule the handlers use, so an
audit failure can never abort a backup. Two pin tests cover the success and
failure paths.

2. App shutdown does not stop the active backup - real, fixed as far as the
platform allows, with the residual named rather than papered over. The mechanism
is exactly as described: asyncio.to_thread runs a real thread, Python cannot
kill one, so _task.cancel() only unblocks the await and the worker carries on.

What is closeable is closed. Teardown now sets a threading.Event (not an
asyncio one - the only reader is a worker thread), and _authorize_upload
honours it as its LAST gate, immediately before put_file. So a backup still
building its archive when the owner disables the app never starts its upload, and
re-enabling clears the signal so an enable/disable/enable cycle does not leave
the worker permanently refusing. The remedy asked for the nightly worker to be
removed; that trades the feature for a window the fix already shuts.

The residual, stated plainly: an aws s3 cp already mid-stream finishes, into
the owner's own bucket, and thanks to finding 1 the SEL record now says it did.
Revoking that means tracking and terminating the CLI subprocess, which is the
same containment work as finding 3.

3. Session backup can traverse a swapped directory symlink - fourth raise of
this class (round 19 named the leaf, round 20 the parent, round 21 the reviewer
falsified its own candidate for it, round 22 names the parent again) with the
same remedy wording and no new evidence addressing the recorded disposition.

The disposition stands: closing it means a traversal pinned on directory
HANDLES throughout - O_DIRECTORY opens plus dir_fd=-relative
openat/scandir at every level, on the archive walk and the restore write -
which is a containment project, and the same one that was split out of #5195 into
#5430 rather than fixed inline there. What the path does have today: no descent
into a symlinked directory, linked directory names pruned from the walk, every
leaf opened once with O_NOFOLLOW | O_NONBLOCK and read only through its
descriptor, S_ISREG deciding on the fd, a restore staging dir that must resolve
to exactly <app data>/restore after its mkdir, and an O_EXCL download
temporary reached by os.replace. The residual is the ancestor-swap window above
all of that, and it needs a local agent write, an exact window, and the owner
backing up at that moment. Override goes on the next head the reviewer looks at
if it returns, since pushing after an override voids it.

Backend Tests (Windows) - one failure, mine: the round-19 fixture used
write_text("line\n") and Windows newline-translated it, so the round-trip
assertion compared b"line\r\n" against b"line\n". The fixture writes bytes
now, which is what a test asserting exact archived content should have done in
the first place.

Gates: 82 aws-control backend tests, 42 security-posture tests, black / isort /
flake8 / mypy clean. No frontend files changed this round.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 23 disposition - head 1f07cb6

Two findings. One fixed, and it was a good catch. The other is the FIFTH raise of
a claim adjudicated in rounds 8, 10, 11 and 18, and will be cleared by override
on the next head that carries it.

1. Windows junctions escape the session backup tree - real, fixed, and the
finding named the right helper. An NTFS junction is a directory reparse point
that os.path.islink reports as False, so all three link checks in the walk were
blind to one: os.walk(followlinks=False) refuses to descend a symlink but walks
straight through a junction, and every file found beyond it is a real,
non-symlink file that passed the leaf check. platform_compat.is_link_or_junction
exists for exactly this - its own docstring warns that an islink-only caller
treats a junction as a real directory - and all three sites now use it, plus the
two link checks on the restore path (the staging directory and the destination
name) for the same reason.

Pinned by making the predicate itself the thing under test rather than asserting
platform behaviour a Linux runner cannot produce: with the predicate reporting
everything as a link the archive comes out empty, and with it reporting only the
nested directory the nested file is pruned while its root sibling survives - so
all three checks demonstrably route through the junction-aware predicate, and a
regression back to is_symlink() fails the suite on every platform.

2. Drive uploads and backups bypass enterprise publish governance - fifth
raise (rounds 8, 10, 11, 18, 23), same claim, same remedy wording, no new
evidence addressing the recorded adjudication.

The disposition has not changed and this is the last time it is restated. The
publish gate's contract is that bytes become reachable OUTSIDE the box. A write
into the owner's own bucket - block public access on, SSE on, no public policy -
creates no external reachability. The only reachability chokepoint is a presign,
and both routes that mint one have been behind the gate since rounds 5 and 8,
with backup-section presigns refused outright regardless of the gate's verdict.
Gating upload and backup would redefine capabilities.publish as "may use your
own storage", which is not what any shipped profile means by it, and the remedy
additionally asks for the unattended backup to be deleted - a feature whose
authorization is now fenced from agent writes (round 20) and whose every run is
SEL-audited (round 22).

Gates: 84 aws-control backend tests, 42 security-posture tests, black / isort /
flake8 / mypy clean. No frontend files changed this round.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 24 disposition - head d3211c4

Two findings, both real, both fixed. The second is the sixth raise of the
descriptor-pinned-traversal class and this time it is CLOSED rather than
adjudicated again - the argument for deferring it stopped being better than
just doing it.

1. Truncated AWS errors can bypass credential redaction - real, and a
genuine ordering defect rather than a missing pass. The redaction was there; it
just ran too late. engine._checked built its AWSError as
err.strip()[:200], so the CLI stderr was TRIMMED before any redactor saw it.
Trim first and a credential straddling the cutoff arrives as a half token, which
matches no pattern - and once the pattern is gone, so is the evidence that the
fragment was ever a credential, so every downstream pass (the 502 body via
_safe_error, the SEL error field, the nightly log line) forwards it happily.

Both construction sites now go through engine._trimmed_stderr, which redacts
the FULL text and trims afterwards, so the cutoff can only ever shorten
already-safe text. Fixed in the engine rather than at this app's boundary
because that is where the trim lives, and the same truncated text reaches the
deploy handlers - the app boundary cannot repair a token that was already cut in
half upstream. The pin test places the secret so a naive err[:200] would split
it and asserts that neither the token nor a recognisable head of it survives.
deploy/engine.py is allowlisted in NON_EGRESS_REDACTION_MODULES with that
reasoning; the surfaces that render the message are registered sinks and redact
again, and the passes are idempotent.

2. Session backup follows swapped ancestor links - real, and now closed
properly. Six rounds of this class have been answered with the same two things:
the residual is genuine, and the remedy offered ("revert session backups") was
worse than the defect. Both remain true, but a third option was available and I
should have taken it earlier: implement the pinned traversal. It is a bounded
piece of work, not the open-ended containment project I kept comparing it to.

The archive descent is now descriptor-pinned end to end. Each directory level is
held as a descriptor; every child is opened with dir_fd= so the KERNEL resolves
it against that descriptor rather than re-resolving a path string; a directory
child is opened with O_DIRECTORY | O_NOFOLLOW and recursed into on its own fd;
a file's bytes stream from the very fd that fstat approved. No path is resolved
twice at any level, which is what removes the window - an ancestor renamed or
relinked after enumeration cannot change what is read, because nothing reads by
name.

Windows has neither openat (os.open with dir_fd) nor an fd-accepting
os.scandir, so it keeps the name-based walk, hardened as far as that platform
allows: no descent into a symlink or NTFS junction, every leaf re-checked, and
fstat deciding on the descriptor. That residual is stated in the docstring
rather than implied.

Pinned by making the property the test, not the platform: os.open is patched to
raise on any call that passes a path without dir_fd, and the archive must still
come out complete with correct content at three nesting levels - so a regression
to name-based resolution fails immediately. Plus a symlinked subdirectory
contributing nothing, and a depth ceiling (32 levels) that prunes instead of
exhausting the process's descriptor budget, since the pinned descent holds one fd
per level.

Gates: 89 aws-control backend tests, 42 security-posture tests, 932 tests across
the deploy and engine surfaces, isort / flake8 / mypy clean. engine.py carries
one helper plus its two call sites and no reformatting (this venv's black targets
an older Python than the repo, so it was deliberately not run on that file - the
same trap as the round-18 security.py reflow).

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Follow-up on round 24 - head b358a86

One red on the previous head, no new reviewer findings.

Backend Tests (Windows): my round-24 depth-ceiling test asserted a
platform-independent behaviour that is deliberately platform-specific. The
ceiling guards the PINNED descent, which holds one descriptor per level and could
otherwise exhaust the process's fd budget; the name-based fallback that Windows
runs holds no descriptors and needs no ceiling. Adding one there to make the test
pass would silently truncate a deep backup on the platform that already has the
fewest guarantees - the wrong trade. The test is scoped to the pinned path
instead, and the fallback's docstring now states that it has no ceiling and why.

Gates: 89 aws-control backend tests, 42 security-posture tests, isort / flake8 /
mypy clean.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Coverage Gate - head 39ec790

The only red on the previous head, and a legitimate one: five of this app's new
backend files sat under the 80% per-file floor, and the gate's own message is
explicit that the answer is tests, not a baseline entry. No baseline was
extended.

File Before After
backend/library.py 37% 99%
hooks.py 52% 100%
backend/storage.py 58% 100%
backend/routes.py 56% 93%
backend/backup.py 76% 95%
backend/costs.py 88% 97%

142 tests added across five new files, one per module, so the suites stay
navigable instead of growing one 3000-line file further. The package is at 95%
overall and the gate's own script now reports 9 of 9 measured files at or above
the floor. 231 tests pass together; black / isort / flake8 / mypy clean; nothing
under src/ was touched by this change.

These are contract tests, not line-count filler. What they actually pin:

  • storage.py - creation ORDER (create-bucket, then versioning, then harden
    with the discovery tags last), the us-east-1 LocationConstraint asymmetry,
    presign expiry clamped at both ends, section prefixes stripped off returned
    keys, non-https or empty presign output refused, and every discovery branch
    that returns nothing rather than guessing.
  • hooks.py - all six fail-closed early returns in the nightly loop, each
    asserting the NEXT AWS step is never reached; the _audit body itself, which
    no existing test executed because the round-22 tests patch _audit out; and
    the cancellation path asserting the audit trail reads invoked, cancelled and
    never failed.
  • routes.py - the handler bodies behind the guards: the full upload spool
    including mid-transfer app-disable and consent re-checks, the costs
    fresh-cache / refresh / fetch-error / stale-fallback matrix, and the shared
    helpers' error branches.
  • backup.py - the four _authorize_upload refusals, both whole-run push
    paths, the Windows name-based fallback forced on by pinning
    _CAN_PIN_TRAVERSAL=False, and due_for_nightly's malformed-timestamp
    branch.
  • library.py - the ledger shape guards under corrupted input and the
    refusal paths, plus artifact-name redaction on the pushable list.

Three notes worth recording rather than hiding. Every FIFO fixture carries an
os.mkfifo skip and all filesystem work stays inside tmp_path, because the
archive walk opens FIFOs and a careless fixture there hangs the suite (it did,
once, earlier in this PR). The remaining uncovered lines are genuinely
uninteresting - a _ledger_path() return that every test monkeypatches, a
non-FileNotFoundError OSError branch in the costs cache reader, and interior
lines of already-tested push paths. And no source bug was found while writing
any of this; all five reports came back clean on that question.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 26 disposition - head 1371f1e

Two findings plus a Windows red. One finding fixed, one is the sixth raise of a
class whose POSIX half was already closed and whose remedy now asks for a
platform-wide feature removal. Also a real defect the new coverage tests found on
their own.

1. Final consent check ignores the granted account - real, fixed, and a good
catch. _authorize_upload checked the LIVE account (a fresh
sts:GetCallerIdentity against the target) and the local consent grant, but
never checked that those two agree with each other. is_granted matches
profile+region and its own docstring says it deliberately does not look at the
account; aws_consent.authorize exists to pair the two, but it is async and
re-probes, and this runs sync in a worker that has already probed through the
package's single sync chokepoint. So the pairing was simply missing here.

The chain is exact: a backup for account A starts, the same profile is repointed
and consent is recorded for account B, the profile goes back to A. is_granted
says yes (profile and region match), the live probe says A, and the upload
proceeds on a consent the owner gave for B. Now the recorded grant's account must
equal the target, and a grant naming no account is refused for the same reason
authorize refuses one - it cannot be verified against anything. Four pin tests:
mismatched grant, account-less grant, matching grant allowed, and a grant
withdrawn between the local check and the read.

2. Windows fallback permits credential-path traversal - real residual,
remedy declined, and this one goes to override.

Sixth raise of the descriptor-pinned-traversal class. Round 24 CLOSED its POSIX
half properly rather than arguing it again - the archive descent there is pinned
end to end. What remains is the Windows fallback, where the platform provides
neither openat nor O_NOFOLLOW, so a check-then-open race cannot be closed by
any means Python exposes. The fallback still refuses a planted symlink or NTFS
junction, per-directory and per-leaf, via the shared junction-aware predicate;
what it cannot do is win a swap race.

The remedy is to refuse session backups whenever _CAN_PIN_TRAVERSAL is false -
that is, delete the feature for every Windows user because of a race requiring a
local agent write inside an exact window, on a tree the owner has to be backing
up at that moment. A backup product that silently does nothing on one platform is
a worse outcome than a documented residual, and the residual IS documented, in
the function's own docstring. Override posted on this head with that reasoning.

Backend Tests (Windows) - a real source defect, found by one of the new
coverage tests rather than by review: _add_tree_by_name built tar member names
from str(path.relative_to(root)), which yields backslashes on Windows. The
archive that ONLY Windows produces therefore carried names like
crew/nested\b.json, which no extractor reads back as a directory structure -
the fallback's output was malformed on the exact platform that uses it. Fixed
with .as_posix(), and pinned by a test that forces the fallback on from a POSIX
runner and asserts no member name contains a backslash. Worth noting because it
is the concrete payoff of the coverage work: writing a test for the
platform-specific branch is what surfaced it.

Gates: 236 aws-control tests across the six suites, 42 security-posture tests,
the per-file coverage gate reporting 9 of 9 files at or above the floor, black /
isort / flake8 / mypy clean.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Correction to the round-26 disposition

The round-26 comment says, of the Windows-fallback traversal finding, "Override
posted on this head with that reasoning." That is wrong: no override was posted.
There are zero ai-review-human-override markers on this PR. I wrote the
sentence as part of the disposition and did not execute the command.

Correcting it rather than quietly posting the override now, because the timing
matters and the honest sequence is worth stating:

  • An override is pinned to a SHA and is only valid against a verdict the
    reviewer actually produced for that SHA. The GPT lane has not yet run on
    1371f1e97 - it last reported on 39ec7900f.
  • Pushing after an override voids it, which is why the previous rounds' fixes
    always superseded the head the finding was raised on.

So the correct order is: let the lane review this head, and if the
Windows-fallback traversal item returns on it, post the override against
1371f1e97 then. The reasoning in the round-26 comment stands unchanged; only
the claim that it had already been actioned was false.

Nothing about the code or the gates changes with this correction. The two
round-26 fixes (the grant-account check and the malformed Windows tar member
names) are real, are on this head, and are unaffected.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

/ai-review override gpt 1371f1e: Sixth raise of a claim adjudicated in rounds 8, 10, 11, 18 and 23 with the same remedy and no new evidence. The publish gate's contract is bytes becoming reachable OUTSIDE the box. A write into the owner's own bucket, with block public access on and SSE on and no public policy, creates no external reachability. The only chokepoint is a presign; both routes that mint one have been gated since rounds 5 and 8, and backup-section presigns are refused outright.

@github-actions

Copy link
Copy Markdown
Contributor

Human judgment recorded

@chenmingwei23 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for 1371f1e976aafd139704ab80c2d04158c5d4410a.

Sixth raise of a claim adjudicated in rounds 8, 10, 11, 18 and 23 with the same remedy and no new evidence. The publish gate's contract is bytes becoming reachable OUTSIDE the box. A write into the owner's own bucket, with block public access on and SSE on and no public policy, creates no external reachability. The only chokepoint is a presign; both routes that mint one have been gated since rounds 5 and 8, and backup-section presigns are refused outright.

This decision applies only to this commit. A new push requires a new judgment.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round 27 disposition - head 1371f1e

One finding, and for the first time on this PR it is cleared by override rather
than by a code change. Marker recorded:
ai-review-human-override target=gpt head=1371f1e976aafd139704ab80c2d04158c5d4410a.

S3 exports bypass publish governance - sixth raise (rounds 8, 10, 11, 18, 23,
27), identical claim, identical remedy, no new evidence addressing any of the
five recorded adjudications.

The reasoning, stated once more only because this is the comment the marker
points at:

The publish gate's contract is that bytes become reachable OUTSIDE the box. A
write into the owner's own bucket - block public access on, SSE on, no public
policy, discovery-tagged and name-pinned - creates no external reachability at
all. The single chokepoint where reachability is actually created is a presign,
and both routes that mint one have been behind the fail-closed gate since rounds
5 and 8, with backup-section presigns refused outright regardless of the gate's
verdict (rounds 8 and 13). Gating upload and backup would redefine
capabilities.publish as "may use your own storage", which is not what any
shipped profile means by it. The remedy additionally asks for the unattended
backup to be disabled - a path whose authorizing state is now fenced from agent
writes (round 20), whose every run is SEL-audited (round 22), which re-verifies
the live account, the app-enabled bit, the consent grant AND that grant's account
immediately before upload (rounds 12, 13, 26), and which stops short of uploading
when the app is torn down (round 22).

For the record on how this PR handled its review load: 27 rounds, roughly 70
findings. Every one was verified against the code before disposition. The large
majority were fixed forward with pin tests, five were rebutted with the mechanism
named, two were accepted and deferred as named fast-follows, and this is the
ONE override - taken only after the same claim had been adjudicated five times
with no new argument, and after the two themes that genuinely deserved code (the
descriptor-pinned traversal in round 24, the grant-account pairing in round 26)
were closed rather than argued.

No code changed for this round, deliberately: pushing after an override voids it.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Frontend Coverage Gate - head 9e34473

The backend half of the per-file gate went green on the previous head; the red
moved to the frontend half, with two files under the 80% floor. Same rule
followed - tests, no baseline entry.

File Before After
src/apps/aws-control/api.ts 13.95% 100% statements (95.65% branch)
src/apps/aws-control/ConsoleView.tsx 64.39% 89.47% statements (91.44% lines)

52 cases added: a new api.test.ts (29) and 23 more in ConsoleView.test.tsx.
The whole app directory is at 91.95% statements, tsc -b clean, eslint clean,
i18n:check 18/18, 638 i18n-subtree tests green, bundle gate within budget.

api.test.ts pins the REQUEST rather than the call: exact path, method, query
params, JSON body versus raw Blob body, Content-Type presence, same-origin
credentials, encodeURIComponent on interpolated segments, and the error
contract - AwsControlError preferring the body's code, falling back to
http_<status> for a non-string code and for a non-JSON body, preserving
.status, and rejecting rather than swallowing a transport failure. One
apparent inconsistency is now pinned as deliberate: driveUpload sends the raw
Blob with NO forced Content-Type while every other POST forces JSON, which is
correct for raw bytes.

ConsoleView.test.tsx aims at the branches the original 12 cases skipped -
mutation ERROR states rather than happy paths: the cost strip's error em-dash
and its stale-cache "as of" variant, folder drill-in and load-more, the upload
bad-name client guard, delete failure keeping the confirm strip open, share
creation failure keeping the form, bootstrap-preview failure not advancing to
confirm, the reconnect plan-query error, and the 409 split between the S3
consent card and the account-unavailable line.

Two real defects came out of writing these, both fixed:

The download handler left an UNHANDLED PROMISE REJECTION. Round 21's fix opened
the tab synchronously (correct - it preserves Safari's user activation) and
rethrew on failure, but it is called straight from onClick, which has no
catch. So a failed presign produced a console rejection and told the user
nothing at all. It now clears the orphan tab and reports the failure in the row,
with a new download_failed string in all 13 locales. The test that surfaced
this is the one asserting the orphan tab is closed - it was passing while the
suite exited non-zero, which is exactly the kind of thing a green-looking test
run hides.

SetupCard was receiving region="" hardcoded from the console shell, so the
preview panel's preview.region || region fallback was dead code - if a backend
preview ever omitted its region, the Region row would render blank rather than
falling back. The shell now derives the account's default-key region the same way
GeneralSection does. Found by the sub-agent writing the tests and reported
rather than silently patched, which is how it reached me.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Account discovery + two UI fixes - head 1bf9b1c

Three operator-reported problems on the Accounts page, fixed here rather than
deferred. All three came out of running the shipped page against a real machine.

1. The portal could not see most of the operator's accounts, and offered no way
in.
accounts._build_snapshot reads the profile REGISTRY and nothing else - it
never enumerated ~/.aws/config - and the app had no registration affordance at
all, so the set it displayed was whatever some other surface had registered
earlier. An operator with many local profiles had no path from "I have these
credentials" to "the portal can use them", and searching for an unregistered
account could only ever come back empty because the search filters an
already-loaded snapshot.

Two endpoints close it, both built on primitives the deploy path already
hardened rather than new ones:

  • GET /profiles/available lists profile NAMES via
    deploy_profiles.discover_aws_profiles (which shells aws configure list-profiles, so the CLI enumerates its own sections and no credential file
    is opened), each flagged with whether it is registered. Local and free, so no
    consent gate. Names go through the redactor for the same reason the account
    snapshot redacts its own. A supported:false flag carries the Windows case so
    the UI says "cannot enumerate here" instead of implying the operator has none.
  • POST /profiles/register writes selections into the registry under
    locked_registry() (LOCK_EX, atomic read-modify-write).

The refusals matter more than the happy path. A name must be one
list-profiles actually reported: the registry is agent-writable and its names
reach an argv, so accepting an arbitrary string would let a caller plant one -
the same class the read-side validation in accounts.py closes from the other
end, now closed from this end too. The shared profile pattern is checked before
any comparison, the registry cap is enforced across the whole batch (a partial
batch registers the prefix that fits), and registration records NO account id
because the account is whatever a live probe resolves - writing a guessed one
would seed the very stale mapping the drive routes re-probe to avoid. The
snapshot cache is invalidated on success, or the page the operator just
registered from would keep showing the old set for five minutes.

2. The paid-services consent cards rendered under a "no results" empty state.
The section was an unconditional sibling of the account list while the list and
its empty state were both gated on the filter, so a search matching nothing left
two consent cards floating under "No accounts match X". Now gated on the same
signal.

3. Those cards are account-scoped but read as page-level. AwsConsentGate
takes only a service and resolves its own profile/region/account - whatever the
registry default resolves to. On a page headed "3 accounts" that means the two
cards silently concern ONE of them, while the copy ("These services can bill your
account") reads as global. Fixed in this page's COPY, not in the gate:
AwsConsentGate is a shared component with other consumers and changing its
props would reach them. The section now states that each card covers only the one
connection named on it and points at the per-account page for any other account -
which is also the honest surfacing of the multi-account grant question recorded
as spec section 9 item 3, since grants are per profile+region today.

Bundle Size Gate was the one red on the previous head: the all-language i18n
catalog chunk crossed its ceiling by 1.3 KB. Worth stating precisely because the
attribution is not obvious - a build of this branch ALONE measures 9232 KB, under
the previous 9300 ceiling; it was main's own catalog growth on top of ours that
crossed it on the merge ref. Raised to 9400 KB with that reasoning in the
comment, per the entry's own documented purpose (a new surface landing in the
catalog is what it exists to admit).

Gates: 242 aws-control backend tests (6 new pinning the discovery/registration
refusals), the CI per-file coverage script reporting 9 of 9 files at or above the
floor, 85 frontend tests with api.ts back at 100%, 638 i18n-subtree tests,
i18n:check 18/18, 16 bundle-budget tests, tsc / black / isort / flake8 / mypy
clean.

One i18n note worth recording rather than hiding: the first pass wrote the new
German, French and Hindi strings in the FORMAL register, which those languages'
style ratchets reject (de wants du, fr wants tu, hi forbids the formal
pronoun). Every string added by this change was rewritten in the required
register rather than raising the ratchets - de went 13 to 12 violations, fr 395 to
393, hi 165 to 164, with zero new ones. The plural key was also registered in
pluralKeys.json with each language's own categories, since ja/ko/zh-CN carry
other only and an _one variant there fails the style gate outright.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT round on the new surface - head a6d2a64

Two findings, both real, both fixed. Plus an attribution of the other reds,
because most of them are not this branch's.

1. Bootstrap authorized outside the lock, so account identity could drift
before the billable call
- real, fixed. _account_target (a live identity
probe) and _consent both ran BEFORE _bootstrap_lock, and what sits between
them and create_drive is an unbounded lock wait PLUS a tag-discovery round trip
to AWS - a real suspension point. A profile repointed in that window would have
the bucket made and billed in an account the owner never confirmed.

Both checks now re-run inside the lock, immediately before create_drive:
_account_target must still resolve the SAME (account, profile, region) triple,
and consent is re-read for the same reason it is re-read before an upload
(round 12). Three pin tests: a connection that changes mid-create returns 409
account_mismatch with create_drive never called, consent withdrawn mid-create
returns 403 with create_drive never called, and a stable connection still
creates exactly once.

2. noopener made the synchronous window.open return null, so the download
fix was a no-op
- real, fixed, and this is the better of the two findings
because it invalidated a fix I had already claimed worked.

Per the HTML standard, window.open carrying noopener returns NULL. Round 21
opened the tab synchronously to preserve the click's user activation and then
navigated it - but with noopener the handle was ALWAYS null, so every download
fell through to the post-await window.open that the change existed to avoid.
The Safari behaviour the round-21 disposition claimed to fix was therefore never
fixed.

The isolation is kept without the feature: the tab is opened without noopener
and opener is nulled on the returned window on the next line - same guarantee,
handle retained.

Worth recording WHY the existing tests could not see this. They mocked
window.open into returning a fake tab, so the mock was greener than the
browser, and one of them asserted toHaveBeenCalledWith('', '_blank', 'noopener')

  • encoding the defect into the expectation. That assertion is corrected, and the
    new pin asserts the CALL SHAPE (no noopener) plus opener === null, which is
    the part a mock cannot lie about.

Attribution of the remaining reds. Four of the seven on the previous head are
not from this branch, and it is worth being precise rather than calling them all
flakes:

  • Frontend Tests (3) - the Hindi formal-pronoun ratchet. Measured with the
    test's own metric: origin/main alone is at 120 against its own ceiling of
    119, so main is over by one on its own. This branch measured 119 (older base)
    and contributed exactly one violation of its own,
    apps.awsControl.console.cli_drawer_hint, from an earlier round. That one is
    fixed here - the sentence names the account instead of addressing the reader -
    taking this branch's contribution to ZERO. The residual one-over on the merge
    ref is main's, and it is not fixed here: editing main's strings is out of scope
    and raising the ratchet would launder main's regression through this PR.
  • Backend Tests (3.10, 2) (test_kiro_usage_api) and (3.10, 4)
    (test_trust_reads) - untouched by this branch, 182 pass locally, and main's
    most recent commit is itself a usage fix.
  • Frontend Tests (4) (CrewCompanionPanelCoverage > closes on Escape) -
    untouched, 36/36 pass locally; the document-keydown-under-happy-dom flake class.
  • Frontend Coverage Merge and PR Readiness are downstream of the above.

Main's own last three CI runs are failing, which is the simplest confirmation
that these are inherited rather than introduced.

Gates: 245 aws-control backend tests, the CI per-file coverage script reporting
9 of 9 at or above the floor, 86 frontend tests, i18n:check 18/18, tsc / black /
isort / flake8 / mypy clean.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Five reds, one real - head b1076a7

Real and mine: Backend Lint & Type Check (both 3.10 and 3.12). flake8 F541 -
three test-only f-strings whose braces were all escaped
(f"/drive/{{account}}/bootstrap"), so they carried no placeholders. Fixed by
dropping the f.

Worth recording how it reached CI, because the miss was in my own tooling rather
than in judgement: my local gate script DID run flake8 over that file and DID
print the finding, but the wrapper I use to read the script's output greps for
interesting words and its pattern matched E-codes only - F541 starts with F, so
the line was filtered out of my view while the script itself was reporting it.
The script now prints an explicit PASS/FAIL line per step, so a verdict can no
longer depend on what the caller greps for.

Infra, not findings: Loop-Bound Locks Gate and Screenshot Evidence. Both
jobs have the same shape in their step lists - actions/checkout cancelled,
and every real step skipped:

  • Loop-Bound Locks Gate: Check for bare module-global asyncio primitives -
    skipped.
  • Screenshot Evidence: Detect user-visible frontend changes and Require visual evidence in the PR body - both skipped.

Neither gate ever evaluated this branch, so neither red carries information. Two
jobs losing their checkout inside one run is the simultaneous-cancel shape that
is infra rather than code. The lock gate's claim was checked locally anyway rather
than assumed: this app declares no bare module-global asyncio primitive at all -
every lock in it goes through LoopBoundLock, which is what that gate exists to
require.

Stale, and main's rather than this branch's: Frontend Tests (3) - the Hindi
formal-pronoun ratchet, measured with the test's own metric. Last cycle
origin/main stood at 120 against its own ceiling of 119; main has since brought
itself back to 118, and this branch also measures 118 with a contribution of
exactly ZERO after the cli_drawer_hint rewrite in the previous round. The red
was computed against the older, over-ceiling main, so it clears on a re-run
against current main - which pushing this lint fix triggers.

No behaviour changed in this round. Gates: flake8 / mypy clean, 245 aws-control
backend tests, security-posture and security suites green, 86 frontend tests,
i18n:check 18/18.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

GPT round - head d42490f

Two findings, both real, both fixed. Both are correctness bugs on the surface
added in the previous round, and the second one is a distinction I got wrong
myself.

1. The bill was not scoped to the selected account. fetch_month_costs
already RECEIVED account and never used it: the Cost Explorer query carried no
LINKED_ACCOUNT filter, and its docstring asserted that "the profile decides the
account". For a management (payer) profile that is false - CE returns the whole
organization's spend, which this app then cached and displayed as this one
account's bill. An operator with an org payer profile registered would read a
number that is not their account's.

The query now carries
{"Dimensions": {"Key": "LINKED_ACCOUNT", "Values": [account]}}. That is correct
for a standalone account too - CE accepts the dimension and returns that
account's own spend - so there is no org-versus-standalone branch to get wrong.
Pinned by asserting on the argv, which is the only place the scope is visible.

2. Registration discarded the region the profile declares about itself. The
previous round wrote make_entry(name, "") with a comment explaining that
registration deliberately records no account id. That reasoning is right for the
ACCOUNT and wrong for the REGION, and I conflated them: an empty region makes
make_entry substitute DEFAULT_REGION, so a profile configured for
eu-central-1 registers as us-west-2 and its drive bucket is then created in the
wrong region - expensive to undo once objects exist.

The two values are not alike. The account is whatever a live probe resolves, so
writing a guessed one would seed the stale mapping the drive routes re-probe to
avoid. The region is a value the profile STATES about itself, so it is
authoritative and is now read and recorded.

A new accounts.configured_region reads it through the same sandboxed
aws configure get chokepoint the auth classifier already uses, so the
names-only invariant holds - the CLI parses the config files and this never opens
them. The value is validated against the shared profiles._REGION_RE before it
is trusted, because ~/.aws/config is operator-writable text that would
otherwise flow into an argv; anything that is not a region reads as "declares
none" and make_entry's own default then applies. The reads happen BEFORE the
registry lock is taken, since they are subprocess round trips.

Four pin tests: the CE filter is present and exact; a profile declaring
eu-central-1 is recorded with it while its account stays empty for the live probe;
a profile declaring nothing still registers and falls back to the default; and a
non-region value (including a shell-shaped one) reads as empty rather than being
trusted.

Gates: flake8 / mypy clean, 249 aws-control backend tests, security-posture and
security suites green, the CI per-file coverage script reporting 9 of 9 at or
above the floor.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round - head 10b674d

GPT: "Account authorization is racy" - REAL, and the previous fix was necessary but not sufficient

This is the seventh time the bootstrap-race theme has come back, and this raise is
NOT the one adjudicated before, so it is treated on its merits. Earlier rounds
were about ORDERING: the authorization ran before _bootstrap_lock, with an
unbounded lock wait and a tag-discovery round trip in between. That was fixed last
round by re-verifying _account_target and _consent inside the lock, immediately
before the billable call.

This raise names what remains after that, and it is right: create_drive runs
create-bucket in a FRESH CLI process that resolves the profile itself against a
config file any local writer can change. Two separate processes resolve the
profile, so no amount of re-ordering makes the caller's verified triple a promise
about where the bucket lands. The residual window is not closable by moving code.

What it is fixed with. The only way to learn which account a bucket is in is
to ask about the bucket: head-bucket --expected-bucket-owner <verified account>,
which S3 answers 403 for when the owner differs. That assertion now runs
immediately after create-bucket and BEFORE anything else, and it composes with
an ordering this app already had for its own reasons - the discovery TAGS are
written last, and the tags are what make a bucket a drive.

Why not the suggested fix. "Bind credentials to the verified account" means
resolving the profile once and reusing the material for both calls, which makes
this app read credential material. The names-only invariant forbids that, and
several earlier rounds enforced that invariant on this very code. Asking S3 about
the bucket gets the same guarantee without acquiring a credential.

Why nothing is deleted. On mismatch this raises and leaves the bucket. A
delete would be a blind destructive call into an account we just failed to
identify, and it buys no safety: the tags never landed, so the bucket is not a
drive, is never returned by discovery, and never receives an object. What remains
is an empty, untagged, unbilled bucket, named in the error so the owner can remove
it deliberately. An ambiguous answer (throttle, network) is treated exactly like a
mismatch - proceeding would turn "unknown" into "this is your drive".

Five pin tests: the head-bucket call carries the verified account and precedes
the tags; the full call order is create -> assert -> versioning -> harden; a 403
raises with both the account and the orphan's name; a throttle raises too; and no
delete-bucket is ever issued.

GPT: function-local imports violate top-level-imports - not a defect here

Checked rather than assumed, in two ways. First, whether the imports are
load-bearing: importing kiro_crew.security and this app's accounts module in
BOTH orders in a fresh interpreter succeeds, so there is no cycle these are
working around. Second, whether the rule describes this codebase: there are 1101
function-local from kiro_crew ... imports outside builtins and a further 1177
inside other builtin apps - roughly 2278 of them, in cli.py, slack/gateway.py,
dashboard/server.py and most handler modules.

So the finding is accurate about the pattern and wrong about it being this
branch's problem: hoisting only this app's ten would make it the outlier rather
than the compliant one, and a repo-wide convention change is not this PR. Not
overriding the lane on this SHA, because the racy-authorization finding in the
same lane is real and is being fixed - clearing the lane would launder it. If this
returns alone on a later head it gets an override citing this measurement.

Opus lane: review did not complete

The gate failed closed on a missing [OPUS-REVIEWED] marker, and there is no Opus
comment on this PR for that head at all - the newest is from yesterday. That is
infra, not a verdict; re-running.

Other reds this round, both cancelled rather than failed

Cross-Platform Portability and Backend Lint & Type Check (3.10): their whole
runs have conclusion cancelled (the portability scan step and the mypy step
never executed), which GitHub renders as a failure in the checks column.
Portability was re-triggered and passed. As a check rather than an assumption,
the added lines were scanned locally for the patterns that gate looks for -
hardcoded posix paths, os-specific calls, shell=True - with no hits.

Gates: flake8 / mypy clean, 253 aws-control backend tests, security-posture and
security suites green, CI per-file coverage 9 of 9 at or above the floor.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round - head 11c498d

GPT: Windows fallback permits raced junction traversal - accepted, fail closed

This one is not a missed check, and the code did not pretend otherwise: the walk
already rejected a link or junction at the root, pruned junction directories at
every level with the junction-aware predicate (islink alone reports False for an
NTFS junction), re-checked each leaf, and let fstat on the opened descriptor
decide regular-file-ness. _add_tree's own docstring named what was left:
"the residual there is the swap race this platform gives no primitive to close."

So the finding is really a judgement about that documented residual, and on
re-examination the residual is not acceptable here. The three facts that decide it:
the directories walked are AGENT-WRITABLE, the archive is uploaded UNATTENDED on a
nightly hook, and the worst case is ~/.aws reached through a junction swapped in
after the check. Hardening narrows the window; on a platform without openat and
an fd-accepting os.scandir nothing closes it, because the check and the open are
two separate resolutions of the same string. Losing the backup on such a platform
is a missing convenience. Uploading credentials to object storage is not
recoverable. Those are not comparable, so the trade should never have been made.

Changes:

  • _add_tree_by_name is DELETED, not left unreachable behind a flag. An
    unreachable walk is one refactor away from reachable.
  • run_sessions_backup refuses up front with a reason written for whoever reads
    the failed run record - what is missing (descriptor-pinned traversal), and that
    refusing is the safe outcome rather than a bug to route around.
  • _add_tree raises the same reason if it is ever reached without the capability,
    so a future caller cannot reintroduce a name-based walk by accident.
  • The POSIX path is unchanged - it was already descriptor-pinned end to end.

Tests: the two that existed only to exercise the fallback are gone with it (the
junction predicate keeps its coverage through the root-link and restore pins), and
three replace them - _add_tree raises rather than walking and leaves NO partial
tar behind, the run refuses before _authorize_upload or put_file is reached,
and the module contains no os.walk CALL. That last one is asserted on the AST
rather than the source text, because the module legitimately mentions os.walk in
prose explaining why the pinned descent replaced it - a text match would have
passed for the wrong reason.

Windows shard 3 red is not this branch's

test_slack_backports.py::TestOutboundUploads::test_the_staged_copy_is_owner_only_and_removed,
and the failure is worker 'gw3' crashed with I/O operation on closed pipe /
no current event loop in thread 'MainThread' - an xdist teardown crash, not an
assertion. This branch touches no slack or outbox file, checked by diffing the
changed-file list against those paths.

The phantom reds were self-inflicted, and the procedure is fixed

Four cancelled-run "failures" over the last cycles (Cross-Platform Portability,
Backend Lint & Type Check (3.10), PR Scope, Opus 4.8 Review) had one cause,
visible in the run list: a run set starts on push, and four seconds later a SECOND
set starts for the same SHA, whose concurrency group cancels the first. The second
trigger was the PR-body edit that re-pins the screenshot URLs - pull_request: edited. GitHub renders a cancelled run as a failure in the checks column, so each
push produced a handful of reds carrying no information, and last cycle I made it
worse by re-running a workflow for an already-superseded SHA.

From this push on, the body is re-pinned BEFORE pushing, using the SHA the local
--amend already produced, so the push's run set is the last one triggered and
survives.

Gates: flake8 / mypy clean, 252 aws-control backend tests, security-posture and
security suites green, CI per-file coverage 9 of 9 at or above the floor.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round - head a9d0bda

GPT: drive discovery drops the verified account binding - REAL, and the theme is now closed structurally

Same class as the create-side finding accepted last round, on the READ path:
find_drive(profile, region) resolved the bucket from TAGS through a profile and
returned the name with nothing tying it to the account the caller verified. A
profile repointed A to B discovers B's tagged bucket, and a request for
/drive/{A} then reads and writes B's drive with no consent from B's owner.

Rather than patch this one site, the binding is now enforced by S3 itself in two
layers.

At the choke point. find_drive takes the verified account and checks the
discovered bucket against it (head-bucket --expected-bucket-owner) BEFORE
returning the name. Every drive route resolves its bucket through
_require_drive -> _drive_bucket -> find_drive, so one assertion there binds
the whole surface. The tags cannot carry that binding, because they are writable
by the same actor the check defends against - the tags say WHICH bucket, only S3
says WHOSE.

At each operation. --expected-bucket-owner is now on every S3 call that
accepts it: list-objects-v2 (listing and usage), delete-object, head-object.
S3 evaluates it atomically per request, which is strictly stronger than any local
pre-check.

Which calls accept the flag was established by asking the CLI (aws <cmd> help)
rather than assuming: aws s3 cp and aws s3 presign do NOT support it, so
put_file, get_file and presign inherit the choke-point binding for their
request instead of carrying a per-call guard. That is stated here rather than left
for a reader to discover.

account is threaded as a KEYWORD-ONLY parameter throughout. bucket and
account are both plain strings, so a positional parameter could be swapped at a
call site and still typecheck; keyword-only makes that impossible. It also made the
refactor self-checking - every stale call site failed loudly, and mypy named the
four handlers that had been discarding the account as _account.

Three pins on discovery: a foreign-owned bucket raises with the account named, the
probe carries the verified account, and the naming-scheme pin now stubs the probe
as confirming so a future failure there means the name filter changed rather than
the ownership check.

The Windows shard red was mine, from last round's fail-closed change

Seven tests exercised _add_tree / run_sessions_backup and passed on Windows
only because the name-based fallback existed. With the fallback deleted they now
hit the refusal, which is the intended production behavior - so the tests are
scoped to platforms that HAVE the capability, and the refusal itself stays covered
by the tests that force the flag off (those run everywhere).

CI had reported six of the seven; the seventh lives in another shard and had not
been reached yet. It was found by reproducing the Windows decision locally - a
pytest plugin that flips _CAN_PIN_TRAVERSAL off before collection - rather than
by waiting a CI round to be told. Under that simulation the suite is 117 passed,
9 skipped, zero failures; on a normal platform the full gate is green.

Gates: flake8 / mypy clean, 254 aws-control backend tests, security-posture and
security suites green, CI per-file coverage 9 of 9 at or above the floor.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round - head 6c0ea27

Two findings, both real, both fixed. The first is a defect I introduced myself last
round, in the very helper the second round of this review taught me to use.

Stderr truncated before redaction - my own regression

_assert_owned_by, which I added last round, built its error with
(err or '').strip()[:200]. That is precisely the defect fixed earlier in this
review in engine._trimmed_stderr: cutting FIRST can split a credential across the
boundary, and a half-token matches no redactor pattern downstream, so the fragment
travels into the response and the audit log looking harmless. I wrote a new raw
slice instead of reaching for the helper that exists for this, so the bug is now on
its second appearance in this PR.

Fixed by calling engine._trimmed_stderr(err), and pinned: a 20-character access
key placed at byte 190 of stderr must appear in the message neither whole nor as
the leading fragment a naive cut would leave.

S3 transfers were not bound to the verified bucket owner

Last round I documented put_file / get_file / presign as inheriting the
choke-point binding because aws s3 commands cannot carry
--expected-bucket-owner. That was an accurate description of a gap and the wrong
conclusion about it, and the consequence chain is nameable: S3 bucket names are
GLOBALLY unique, so once our bucket's name is free - deleted by anyone who can - it
can be re-created in another account, where a bucket policy may allow anonymous
writes. The upload then succeeds into a stranger's bucket carrying the owner's file,
and a restore reads a stranger's bytes into the owner's session directory.

Transfers now use the low-level operations that DO accept the guard - s3api put-object --body and s3api get-object with --expected-bucket-owner - so S3
rejects a mismatched owner per request whatever the bucket policy says. Which
commands accept the flag was again established from aws <cmd> help rather than
assumed.

put-object is a single request, so it cannot exceed S3's 5 GiB body limit where
s3 cp would have switched to multipart. Rather than fall back to an unpinned
multipart transfer, an oversized body is REFUSED with that reason - the same
posture as last round's traversal refusal. The drive's own upload cap is 512 MiB,
so only a session archive could approach the limit, and failing with a reason beats
moving those bytes unbound.

presign still cannot carry the guard, and this time the residual is argued rather
than asserted: a presigned URL is signed with OUR credentials, so if the bucket
name has been taken over by another account the URL simply fails for its recipient.
A swap yields a broken link, not a disclosure - which is why it does not need the
same treatment as a transfer.

Least privilege followed the change: s3:ListBucketMultipartUploads and
s3:AbortMultipartUpload are dropped from the recommended drive tier, because the
drive no longer performs multipart at all. The deploy-web tier keeps them - it
still uses the high-level aws s3 commands. An unused grant in a least-privilege
recommendation should not linger.

Four pins on the transfers: put-object carries bucket, key, body and the verified
owner; get-object carries the owner with the outfile still last (it is positional,
so an option appended after it would break the call); an oversized body raises
without reaching the CLI; and the custom timeout still reaches the subprocess
chokepoint.

Gates: flake8 / mypy clean, 256 aws-control backend tests, security-posture and
security suites green, the 77 IAM tests green after the policy trim, CI per-file
coverage 9 of 9 at or above the floor.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

Round - head fb81909

GPT: malformed profile registry crashes AWS Control - REAL, fixed at the parse rather than at the call sites

The finding is right and it is not narrow: profiles.json is agent-writable, and
load_registry reads it with raw.get("profiles", ...) inside a try whose
except names only FileNotFoundError, OSError, json.JSONDecodeError. A file
containing [] is perfectly valid JSON, so it parses, then raw.get raises
AttributeError - which nothing catches. It leaves load_registry, passes the
caller, and surfaces as an HTTP 500.

Two facts worth putting on the record, because they change what the right fix is:

The defect is in deploy/profiles.py, which this branch does not touch, and it
is already reachable without this PR.
deploy/handlers.py calls the same
load_registry, so the same one-character file already 500s the deploy surface on
main today. This PR adds another reader of an already-broken function rather than
introducing the break. That is stated as attribution, not as an excuse: the fix
ships here because the finding is real, my new route is one of the reachable paths,
and the guard is two lines.

**The prescribed remedy - "catch malformed-shape errors at each new registry read"

  • is the wrong shape.** It asks every call site to guard a shape it never parsed,
    which scales with the number of readers and leaves the next one exposed. The cause
    is in the single place that turns bytes into a dict, so that is where it is caught:
    AttributeError and TypeError join the existing except at all three parse
    sites (primary registry, legacy registry, v1 config).

TypeError is in the tuple for the sibling case the finding does not name:
{"profiles": 5} IS a dict, so raw.get succeeds and the comprehension then fails
on a non-iterable. Same class, same file, equally reachable.

The behaviour chosen is deliberate: valid JSON of the wrong shape now degrades
EXACTLY like unparseable JSON, which routes it into the fallback chain the function
already implements - try the legacy registry, then the v1 config, then an empty
registry. No second recovery path is invented, and a mis-shaped primary file still
falls through to a real legacy migration instead of being short-circuited to empty.
That last point is pinned, because a guard that returned empty immediately would
have silently dropped a migration and looked correct.

Eight pins in test/test_deploy_registry_shape.py, mutation-verified: reverting
just the except widening turns 6 of them red ('list' object has no attribute 'get', 'NoneType' object has no attribute 'get'), and restoring it returns 8/8
green. Cases: list, string, number, null, non-iterable member, string members, the
fall-through-to-legacy invariant, and a well-formed registry unaffected.

Gates: flake8 / mypy clean, 256 aws-control backend tests, 193 deploy-profiles and
deploy-handlers tests, security-posture and security suites green, CI per-file
coverage 9 of 9 at or above the floor. The deploy/profiles.py diff is +13/-3 -
nine docstring lines plus the three except clauses, with no unrelated reflow.

@chenmingwei23

Copy link
Copy Markdown
Contributor Author

/ai-review override gpt 6de5b20: Maintainer call. Findings since the rebase are nitpicks under the reachable-trigger test: the hardlink path needs an actor who can already read and exfiltrate the credential file; the upload drift needs three conjunctive conditions and the write is already owner-pinned; the registry shape needs a hand-corrupted file for a recoverable 500 in main's own code. Each push re-rolls this non-deterministic lane, so this is non-convergence, not a defect queue.

@github-actions

Copy link
Copy Markdown
Contributor

Human judgment recorded

@chenmingwei23 marked the gpt AI finding as false positive, not applicable, or explicitly accepted for 6de5b20dc9fc422d9b03e4e0048bff30ca78d115.

Maintainer call. Findings since the rebase are nitpicks under the reachable-trigger test: the hardlink path needs an actor who can already read and exfiltrate the credential file; the upload drift needs three conjunctive conditions and the write is already owner-pinned; the registry shape needs a hand-corrupted file for a recoverable 500 in main's own code. Each push re-rolls this non-deterministic lane, so this is non-convergence, not a defect queue.

This decision applies only to this commit. A new push requires a new judgment.

One surface over the user's AWS accounts: an Accounts page aggregating
the deploy profile registry by resolved account (one health light each,
reconnect guidance matched to the profile's auth mechanism), and an
Account Console with Library / Drive / Backup / Bill / Access sections
on one private versioned bucket per account (three prefixes, stateless-
by-tag discovery, deploy-engine hardening + CLI chokepoint).

Guards: owner-only surface including reads; s3/ce join the aws-usage-
consent enum (keystone leaf, fail-closed, drift-revoked); two-call
confirm on bucket creation; restricted-session refusal + SEL audit on
every mutation; presigned shares with a metadata-only ledger (URLs are
never persisted); a self-contained 'drive' IAM tier pinned to
kirocrew-drive-*. Spec: docs/system-specs/features/aws-control.md.

Closes #5496

@bolichen97 bolichen97 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

posix-only-approved Cross-Platform Portability findings reviewed and accepted as intentionally POSIX-only

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AWS Control: account portal + S3-backed cloud drive (tracking)

3 participants