diff --git a/.claude/skills/check/rules.py b/.claude/skills/check/rules.py index 7eaf62d..f44d5ed 100644 --- a/.claude/skills/check/rules.py +++ b/.claude/skills/check/rules.py @@ -7,7 +7,7 @@ Errors (exit 1): - a top-level import that isn't the standard library, boto3 or botocore (optional packages load lazily) - an import of another analyzer (shared code goes in _kit/), except a companion importing the analyzers it - builds on (COMPANIONS: s3_explorer.py on s3.py, the agent/ package on lambda_functions.py); an import of a + builds on (COMPANIONS: s3_explorer.py on s3.py, the agent/ package on lambda_functions.py and s3.py); an import of a module a few analyzers share by any other analyzer (SHARED: _bedrock.py, for bedrock_kb.py, bedrock_chat.py and agent/); a shared module importing an analyzer; and _kit/ importing any of them @@ -44,8 +44,9 @@ ALLOWED_TOP_LEVEL = {"boto3", "botocore"} # A companion builds on analyzers: it imports them (lazily, so it still imports alone), its UI class is a # View or Explorer, and its AWS calls are checked against those analyzers' services. The agent -# builds on every analyzer one of its toolkits reads through (agent/toolkits/: LambdaToolkit on LambdaAnalyzer). -COMPANIONS = {"s3_explorer": ("s3",), "agent": ("lambda_functions",)} +# builds on every analyzer one of its toolkits reads through (agent/toolkits/: LambdaToolkit on LambdaAnalyzer, +# S3Toolkit on S3Analyzer). +COMPANIONS = {"s3_explorer": ("s3",), "agent": ("lambda_functions", "s3")} # Code a few analyzers share that the others must not load: a module of the package beside them -> the analyzers # that import it. It's checked like _kit (no sections or View), imports _kit and never an analyzer, and the # analyzers that import it don't define its names again. @@ -78,6 +79,7 @@ "s3:ListBucketMetricsConfigurations": "s3:GetMetricsConfiguration", "s3:GetObjectLockConfiguration": "s3:GetBucketObjectLockConfiguration", "s3:GetPublicAccessBlock": "s3:GetBucketPublicAccessBlock", + "s3:GetBucketNotificationConfiguration": "s3:GetBucketNotification", "s3control:GetPublicAccessBlock": "s3:GetAccountPublicAccessBlock", "dynamodb:ExecuteStatement": "dynamodb:PartiQLSelect", "bedrock-runtime:Converse": "bedrock:InvokeModel", diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ee62dc..74d9aed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,21 @@ changes what one shows, and a patch (0.1.1) only fixes things. A change that cou `Toolkit` of your own (from `aws_analyzer.agent`) beside the Lambda one, and its tools get the same budget, error handling, read-only guard and report. The agent guide's "Your own tools" has an example. +- **The agent covers S3 too, and the bucket events that invoke Lambda functions**: + `AgentView().ask("Were last night's uploads to acme-raw processed?")` checks the bucket's event notifications, the + files that arrived in the window and the function they should have run, and says what broke the link: files ending + in `.CSV` when the filter says `.csv`, files uploaded in parts when the notification fires only on PUT uploads, a + function that isn't there, a missing permission (with the `aws lambda add-permission` command), or no file arriving + at all, next to the function's runs and failures in that window. It also answers about buckets on their own (their + setup, size and cost, what's in a folder, which files arrived when, one file's headers), and never reads what's + inside a file. `AgentView(toolkits=["s3"])` gives S3 alone, and `budget={"s3_keys": 500_000}` lets a listing read + more than its default 100,000 keys. + +- S3: `S3Analyzer().notifications("my-bucket")` lists a bucket's **event notifications**: which Lambda functions, SQS + queues and SNS topics it sends which events to, for which keys, and whether EventBridge gets them all + (`parse_notifications` does the same on a `GetBucketNotificationConfiguration` response). It needs + `s3:GetBucketNotification`. + - `set_timezone("Asia/Kolkata")` (from `aws_analyzer`) shows **times in your own time zone** instead of UTC, in every report and window of every service, and their columns and sentences say which zone (`Last modified (IST)`, `since 2024-05-01 14:02 IST`). It takes a tz database name, its city alone (`"kolkata"`, `"new york"`), `"local"` diff --git a/CLAUDE.md b/CLAUDE.md index e604368..a06ce3c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,9 +17,9 @@ would have hidden it), and `lambda_functions.py` isn't `lambda.py` because `lamb (`from aws_analyzer import lambda` is a syntax error). Up to 0.13.0 each analyzer was also a standalone file to copy next to a notebook; that's gone, and the package is the one way in. `s3_explorer.py` is a **companion** to `s3.py`: a clickable file explorer (ipywidgets) that imports `s3` for its -previews, formatting and AWS calls. `agent/` is a companion to `lambda_functions.py`: `AgentView().ask("Which Lambda -failed last night?")`, an agent (LangChain's deepagents, on an Azure OpenAI deployment) answering questions from -read-only tools built on `LambdaAnalyzer` (see "The agent" below). It's the one analyzer that is a package rather +previews, formatting and AWS calls. `agent/` is a companion to `lambda_functions.py` and `s3.py`: `AgentView().ask("Which +Lambda failed last night?")`, an agent (LangChain's deepagents, on an Azure OpenAI deployment) answering questions from +read-only tools built on `LambdaAnalyzer` and `S3Analyzer` (see "The agent" below). It's the one analyzer that is a package rather than a file, because it grows by toolkits (one module per AWS service it has tools for) and by capabilities of the agent itself, each in its own module. @@ -183,9 +183,11 @@ cd src && for f in aws_analyzer/[!_]*.py aws_analyzer/[!_]*/__init__.py; do m="$ which `rules.py` can't see. `how_it_works()` reads the package's Python with `ast` and never runs, imports or `exec`s any of it. Environment variable values never reach a report: `parse_function` keeps their names, and `Function.raw` (the configuration shown folded) has the values replaced by `(hidden)`. The agent only calls the - analyzers' read methods, and `_Guard` hooks every client `LambdaAnalyzer` makes for it (`attach` wraps its - `_service`) on `before-parameter-build`, refusing any operation outside `READ_PREFIXES` (Get, List, Describe, - Filter) with `ReadOnlyError` before its request is built; the model is reached through LangChain, not boto3, so + analyzers' read methods, and `_Guard` hooks every client a toolkit's analyzer makes for it (`attach` swaps its + `session` for a `_GuardedSession`, whose `client()` hooks what it makes, and wraps Lambda's `_service`, which also + hands out clients passed in) on `before-parameter-build`, refusing any operation outside `READ_PREFIXES` (Get, List, + Describe, Head, Filter) with `ReadOnlyError` before its request is built; the S3 tools read names, sizes, dates and + headers, never an object's body; the model is reached through LangChain, not boto3, so `rules.py` sees none of it. Keep it that way; README lists the read-only IAM permissions per service, so update that list when a new AWS API call is added. `rules.py` finds the services from `session.client("", ...)` calls, so create each client with its service name spelled out (see `SageMakerAnalyzer._service`), or its @@ -596,11 +598,22 @@ functions); a test patches a name in the module that looks it up (`harness._MIN_ the result. A toolkit also declares `reads` (each tool's AWS reads, for `tools()` and README: `TOOL_READS`), `service` / `services` (the IAM prefix of each operation, for `permission_for`), `covers` (the system prompt's first paragraph), `instructions(budget)` (its section of the prompt: Lambda's says start with `function_health`), - `privacy` (the tools() note), `not_found` (close names for a resource that isn't there) and `client_factory` (the - analyzer method whose clients the guard hooks: Lambda's `_service`). Its `analyzer` is made on the agent's session - on first use and hooked to the guard every time. `TOOLKITS` names the built-in ones and `DEFAULT_TOOLKITS` what an + `privacy` (the tools() note), `not_found` (close names for a resource that isn't there), `permissions` (an + operation's IAM permission where it isn't `:`: S3's `ListObjectsV2` is `s3:ListBucket`), + `request_cost(counts)` (what its requests cost, from the guard's counts: S3's, at `S3_REQUEST_PRICES`) and + `client_factory` (the analyzer method whose clients the guard hooks: Lambda's `_service`). Its `analyzer` is made on + the agent's session on first use and hooked to the guard every time. + `S3Toolkit` (`s3_tools.py`) has `find_buckets`, `bucket_details`, `list_folder`, `find_files`, `folder_summary` and + `file_info` over `S3Analyzer`; listings stop at the budget's `s3_keys` and say so (`scanned`, `cut_short`). **A tool + that joins two services** goes in one of their toolkits and reads the other through the agent's toolkit for it, + `peer(name)` (None when the agent hasn't got it, and then `tools()` leaves the tool out): S3's `s3_triggers` reads + the bucket's notifications (`S3Analyzer.notifications`), lists the files each one covers in the window + (`count_arrivals`, pure: by prefix and case-sensitive suffix, and by upload kind, since a multipart upload sends + CompleteMultipartUpload, not Put), and reads the function, its resource policy (Lambda's `parse_policy`) and its + CloudWatch numbers through `peer("lambda").analyzer`; `trigger_findings` (pure) says what broke each link. Its + `covers` and `instructions()` add the S3-and-Lambda part only when the peer is there. `TOOLKITS` names the built-in ones and `DEFAULT_TOOLKITS` what an agent gets; `AgentAnalyzer(toolkits=[...])` takes names, `Toolkit` subclasses or objects (a user's own too), and - `lambda_core=` / `lambda_clients=` / `prices=` go to the Lambda toolkit it makes. Two toolkits can't share a tool + `lambda_core=` / `lambda_clients=` / `prices=` go to the Lambda toolkit it makes, `s3_core=` to the S3 one. Two toolkits can't share a tool name (`tools()` refuses). **A new service** is a `_tools.py` here with its toolkit, its name in `TOOLKITS` (and `DEFAULT_TOOLKITS`), its analyzer in `COMPANIONS["agent"]` in `rules.py`, and its reads in README. - **The runtime** (`runtime.py`, `as_tool`) wraps every toolkit's tools alike: it records a `ToolCall` on the @@ -610,9 +623,14 @@ functions); a test patches a name in the module that looks it up (`harness._MIN_ `not_found` first, for close names), and anything else into an error result: a tool never raises into the agent loop. Sections the analyzer couldn't read (`errors` dicts) become the call's error. LangGraph runs a turn's tool calls side by side, so `_Run` is behind a lock and the budget counts under it (`_Run.read_metrics` too: the - CloudWatch metrics a tool requested, and their cost). -- **The guard** (`guard.py`, `_Guard`): `attach` hooks the clients an analyzer has and wraps its client factory, and - refuses any operation outside `READ_PREFIXES` with `ReadOnlyError` before its request is built. + CloudWatch metrics a tool requested, and their cost). LangChain passes every default, so the `ToolCall` keeps only + the arguments that differ from the tool's defaults, and `first` (its first parameter, when it has no default) is + written without its name: `find_files('s3://acme-raw/incoming/', since='2h')`. +- **The guard** (`guard.py`, `_Guard`): `attach` swaps the analyzer's `session` for a `_GuardedSession` (every client + it makes from then on is hooked, whichever method makes it: S3Analyzer makes regional S3, CloudWatch, STS and S3 + Control clients in several places), hooks the clients it already holds (its `client`, and the values of its dicts) + and wraps its client factory, and refuses any operation outside `READ_PREFIXES` with `ReadOnlyError` before its + request is built. - **The model** (`llm.py`): `azure_chat_model()` (`AzureChatOpenAI` from `deployment` / `endpoint` / `api_key` / `api_version` or `AZURE_OPENAI_*`; `_Hint` names what's missing), or `chat_model=`, any LangChain chat model; `model_name`, `answer_cost` (costs need `model_prices=`: Azure has no list prices here, else they're unknown) and @@ -803,7 +821,8 @@ does both, a page at a time). How the UI works: against a `Fake` client put in `sys.modules["boto3"]`. `save_runs()` / `load_runs()` tests `monkeypatch.chdir` into `tmp_path`. - `tests/test_agent.py` runs the tools on moto (with `test_lambda_functions`' `create`, `put_logs` and - `lambda_clients`), a toy `Notes` toolkit with no AWS behind it to show what a new service's toolkit plugs into, and + `lambda_clients`, whose `policies=` gives a function the resource policy `s3_triggers` reads; the `buckets` fixture + uploads its files before it sets a bucket's notifications, so moto doesn't try to run the function), a toy `Notes` toolkit with no AWS behind it to show what a new service's toolkit plugs into, and the agent loop with `scripted(...)`, a LangChain chat model that answers with AIMessages in turn (`calls(...)` for tool calls, `says(...)` for text) and records the tools it was given and the messages it was sent; `test_asking_through_azure_openai` runs `AzureChatOpenAI` itself over an `httpx.MockTransport` that answers as diff --git a/README.md b/README.md index 08fa307..c2db59a 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ OpenSearch vector indexes, Lambda functions and the SageMaker notebook itself: w to do next. And windows you use by clicking: an explorer that shows how a knowledge base indexed each of its files, a chat window for asking it, with every setting in reach, and an explorer for your Lambda functions that reads their logs run by run. Or just ask: an agent answers questions like "Which Lambda failed -last night?" from the same read-only tools, and says what it checked. +last night?" or "Were last night's uploads to acme-raw processed?" from the same read-only tools, and says what it +checked. [![CI](https://github.com/utkarsh5026/aws-analyzer/actions/workflows/ci.yml/badge.svg)](https://github.com/utkarsh5026/aws-analyzer/actions/workflows/ci.yml) [![PyPI](https://img.shields.io/pypi/v/aws-analyzer?color=0f766e)](https://pypi.org/project/aws-analyzer/) @@ -90,7 +91,7 @@ report still renders. | **Amazon OpenSearch** | • Every vector field in plain English: size, engine, similarity
• Whether the vector graphs fit in the memory the nodes have
• Documents without a vector, and zero or repeated vectors
• The nearest neighbours of a question, a vector or a document | [`opensearch.py`](src/aws_analyzer/opensearch.py)
[Guide →](https://utkarsh5026.github.io/aws-analyzer/opensearch.html) | | **AWS Lambda** | • Every function's runtime, triggers, calls, errors and cost, in one region or all
• Runtimes losing support, and functions anyone can call
• Errors grouped by cause, from the function's own logs
• Memory used and cold starts, the code in its package, and how its Python code works, step by step | [`lambda_functions.py`](src/aws_analyzer/lambda_functions.py)
[Guide →](https://utkarsh5026.github.io/aws-analyzer/lambda_functions.html) | | **Lambda explorer** | • Every function and how it's doing, problems first: click one to open it
• Its logs run by run, failed runs in red: search them, pick a time range, or watch them live
• What calls it and where its results go, its errors by cause, its run times
• The code in its package and every setting, by clicking | [`lambda_functions.py`](src/aws_analyzer/lambda_functions.py)
[Guide →](https://utkarsh5026.github.io/aws-analyzer/lambda_functions.html#explorer) | -| **Ask in plain English** | • Ask "Which Lambda failed last night?" and get the answer, with the numbers that matter
• Every answer says what it checked: the time window, the regions, what it couldn't read
• Follow-up questions; the commands to look closer, ready to run
• Read-only by construction, with a budget per question (Lambda for now; the model is your Azure OpenAI deployment) | [`agent/`](src/aws_analyzer/agent/) (with `lambda_functions.py`)
[Guide →](https://utkarsh5026.github.io/aws-analyzer/agent.html) | +| **Ask in plain English** | • Ask "Which Lambda failed last night?" and get the answer, with the numbers that matter
• Every answer says what it checked: the time window, the regions, what it couldn't read
• Follow-up questions; the commands to look closer, ready to run
• Lambda and S3, and the bucket events that invoke functions: were the files that arrived processed?
• Read-only by construction, with a budget per question (the model is your Azure OpenAI deployment) | [`agent/`](src/aws_analyzer/agent/) (with `lambda_functions.py` and `s3.py`)
[Guide →](https://utkarsh5026.github.io/aws-analyzer/agent.html) | The [guides](https://utkarsh5026.github.io/aws-analyzer/) walk through each service with screenshots: setting up in SageMaker, every command, and ready-made IAM policies. Their source is in [`docs/`](docs/). @@ -466,6 +467,7 @@ for obj in s3.iter_objects("s3://my-bucket/"): # stream a listing without h reports = s3.bucket_reports(match="sagemaker-*") # BucketConfig + CloudWatch size per bucket explain_policy(s3.bucket_policy("my-bucket"), s3.account_id()) # [PolicyStatement], plain English +s3.notifications("my-bucket") # [EventNotification]: which events go to which function, queue or topic impact = s3.simulate_lifecycle("s3://my-bucket/logs/", move_after=30, to="STANDARD_IA") impact.monthly_savings, impact.rule() # USD per month, the rule as a dict s3.deleted_files("s3://my-bucket/data/", deleted_after="7d").files # [DeletedObject] @@ -481,8 +483,8 @@ s3.clean_downloads(older_than="30d").freed # DownloadsCleanup; dry_run= The aggregation functions are pure (no AWS calls), so they also work on your own lists of `ObjectInfo`, for example rows loaded from an S3 Inventory report: `summarize_objects`, `build_folder_tree`, `make_filter`, `find_duplicate_groups`, `compare_objects`, `simulate_lifecycle_objects`, `summary_findings`, -`bucket_findings`, `explain_policy`, `policy_findings`, `object_monthly_cost`, `cloudwatch_cost`, the duplicate -finder's steps (`files_to_hash` says which files need reading, `group_duplicates` groups them given the hashes you +`bucket_findings`, `explain_policy`, `policy_findings`, `parse_notifications`, `object_monthly_cost`, `cloudwatch_cost`, +the duplicate finder's steps (`files_to_hash` says which files need reading, `group_duplicates` groups them given the hashes you have, then `duplicate_folders` and `duplicate_findings`), `zip_checks` and `zip_findings` (on a `ZipPlan`), and the file parsers `parse_docx` (`pictures=True` for its pictures), `parse_pptx`, `parse_pdf` (`layout=True` keeps where each line sits, its size and weight), `parse_avro`, and `render_pdf_pages`, which draws a PDF's pages from a local file @@ -598,7 +600,7 @@ Read-only. Grant what you need: | `s3:ListBucketMultipartUploads`, `s3:ListMultipartUploadParts` | Incomplete multipart uploads | | `s3:GetObject` | Reading files: `preview`, `document`, `file_details`, `ls(details=True)`, `duplicates`, `download` / `download_zip` | | `s3:GetObjectTagging` | Object tags | -| The `s3:GetBucket*` / `s3:GetLifecycleConfiguration` / `s3:GetReplicationConfiguration` / `s3:GetEncryptionConfiguration` / `s3:GetInventoryConfiguration` family | The settings in `bucket_info`, including `s3:GetBucketPolicy` for `policy` | +| The `s3:GetBucket*` / `s3:GetLifecycleConfiguration` / `s3:GetReplicationConfiguration` / `s3:GetEncryptionConfiguration` / `s3:GetInventoryConfiguration` family | The settings in `bucket_info`, including `s3:GetBucketPolicy` for `policy` and `s3:GetBucketNotification` for `notifications()` | | `s3:GetAccountPublicAccessBlock` | The account-level public access setting | | `cloudwatch:ListMetrics`, `cloudwatch:GetMetricData` | Bucket sizes | @@ -1925,9 +1927,12 @@ policy. **Questions about your account, answered by an agent.** Ask "Which Lambda failed last night?" and a model calls the package's read-only tools until it can answer: which functions failed and how often, why, and the run that shows -it. Every answer says what it checked (the time window, in your time zone, and the regions), what it couldn't read or -was stopped from reading, what it cost, and the `LambdaView` commands to look closer. Version 1 covers Lambda; the -model is your Azure OpenAI deployment, and the agent loop is LangChain's [deepagents](https://pypi.org/project/deepagents/). +it. Ask "Were last night's uploads to acme-raw processed?" and it joins the bucket's event notifications, the files that +arrived and the function's runs, and finds the files that never ran it (a suffix filter that misses `.CSV`, a +multipart upload a PUT-only notification ignores). Every answer says what it checked (the time window, in your time +zone, and the regions), what it couldn't read or was stopped from reading, what it cost, and the `LambdaView` and +`S3View` commands to look closer. It covers Lambda and S3; the model is your Azure OpenAI deployment, and the agent +loop is LangChain's [deepagents](https://pypi.org/project/deepagents/). 📄 [`src/aws_analyzer/agent/`](src/aws_analyzer/agent/) · 📖 [Agent guide](https://utkarsh5026.github.io/aws-analyzer/agent.html) @@ -1950,21 +1955,24 @@ set_timezone("Asia/Kolkata") # so "last night" means agent = AgentView() agent.ask("Which Lambda failed last night?") agent.ask("Why did orders-etl fail?") # a follow-up: it remembers the conversation +agent.ask("Were last night's uploads to acme-raw processed?", new=True) # S3 and Lambda together agent.ask("Which functions run close to their timeout?", new=True) # a new conversation agent.tools() # what it can call, its model and its budget agent.transcript() # every question and answer so far, with their cost ``` > [!NOTE] -> What leaves AWS: your question and what the tools return (function names, settings, counts, error messages and log -> lines, with anything that looks like a key or password hidden) go to your Azure OpenAI deployment. Environment -> variable values are never read. Use a deployment your organisation allows for that data. +> What leaves AWS: your question and what the tools return (function and bucket names, settings, counts, file names, +> error messages and log lines, with anything that looks like a key or password hidden) go to your Azure OpenAI +> deployment. Environment variable values and what's inside your files are never read. Use a deployment your +> organisation allows for that data. ### What it can call -Seven tools, each a building block over `LambdaAnalyzer` that returns a compact summary with what it covered. The -model combines them: "which failed last night?" is `function_health` for the window, then `function_errors` for the -functions that failed. +Fourteen tools in two toolkits, each a building block over `LambdaAnalyzer` or `S3Analyzer` that returns a compact +summary with what it covered. The model combines them: "which failed last night?" is `function_health` for the window, +then `function_errors` for the functions that failed; "were last night's files processed?" is `s3_triggers`, then +`function_errors` for the runs that failed. | Tool | What it returns | | :---------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -1975,17 +1983,27 @@ functions that failed. | `function_performance(function, since, until, region)` | Median, p95, p99 and longest run against the timeout, timeouts, cold starts, and the memory it needs | | `function_details(function, region)` | Settings, triggers, aliases, its last 30 days, log retention, the estimated monthly cost by part, and every finding with its next step | | `function_code(function, region)` | What a Python function's code calls (AWS operations, environment variables by name, event fields, libraries), read with `ast` and never run | +| `find_buckets(match, sizes)` | The buckets and their regions; `sizes=True` adds each one's object count and size from CloudWatch, biggest first | +| `bucket_details(bucket)` | Public access, encryption, versioning, lifecycle rules in plain words, replication, logging, tags, **its event notifications**, size and monthly cost, and its findings | +| `list_folder(location)` | One level of a folder, like `aws s3 ls`: its subfolders, and its files newest first | +| `find_files(location, match, since, until, min_size, max_size, limit)` | The files under a location that match a name glob, a time window or a size: how many, how big, the newest | +| `folder_summary(location)` | What a location holds by file type, storage class and folder, the largest files, the monthly cost and findings | +| `file_info(location)` | One file's headers, never its content: size, type, storage class (Glacier needs a restore), encryption (KMS needs `kms:Decrypt`), metadata, tags | +| `s3_triggers(bucket, function, since, until, region)` | **S3 and Lambda joined**: which bucket events invoke which functions, on which keys; whether the function exists and lets the bucket invoke it; the files that arrived in the window and how many ran it (and which were missed: by the case of their ending, or a multipart upload a PUT-only notification ignores); and the function's runs and failures in that window | + +`s3_triggers` is there when the agent has both toolkits, as it does by default; `AgentView(toolkits=["s3"])` gives S3 +alone. No S3 tool reads what's inside a file. ### Safe and bounded - **It only reads.** The tools call the analyzers' read methods, and a guard on every AWS client they use refuses any - operation that isn't a Get, List, Describe or Filter call before its request is built, whatever asks for it. There's + operation that isn't a Get, List, Describe, Head or Filter call before its request is built, whatever asks for it. There's no shell or code execution: deepagents' `execute` and its general-purpose subagent are turned off, and its file tools only keep notes in memory. - **A budget per question.** At most 25 tool calls, 5 minutes and, when you pass the model's prices, $1.00 of tokens; - a log read covers at most 3 days (CloudWatch's counts cover longer windows). Past a limit the tools stop and the - model answers with what it has, and the report says what was cut short. `AgentView(budget={"tool_calls": 50, - "log_days": 7})` raises them. + a log read covers at most 3 days (CloudWatch's counts cover longer windows), and an S3 listing at most 100,000 keys. + Past a limit the tools stop and the model answers with what it has, and the report says what was cut short. + `AgentView(budget={"tool_calls": 50, "log_days": 7, "s3_keys": 500_000})` raises them. - **It says what it checked.** Every tool result carries its window, regions and whether it was cut short, and the agent is told to say so. A missing permission becomes part of the answer, not an error. - **Log lines are data.** What the tools read can contain text that looks like instructions; the agent is told never @@ -1995,7 +2013,7 @@ functions that failed. | Command | Shows | | :-------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ask(question, new=False)` | The answer first, then **what it checked**: each tool call with its window, regions and what it found, what it couldn't read or was refused, the AWS reads, tokens and cost, and the `LambdaView` commands to look closer. Follow-ups continue the conversation; `new=True` starts another. The kernel's stop button stops it, and the report shows what it found until then | +| `ask(question, new=False)` | The answer first, then **what it checked**: each tool call with its window, regions and what it found, what it couldn't read or was refused, the AWS reads, tokens and cost, and the `LambdaView` and `S3View` commands to look closer. Follow-ups continue the conversation; `new=True` starts another. The kernel's stop button stops it, and the report shows what it found until then | | `new_conversation()` | Starts a new conversation: the next question won't see the earlier ones | | `transcript()` | Every question asked so far and its answer, with the tool calls, tokens and cost of each, and the total | | `tools()` | Each tool, what it tells the model and the AWS reads it makes; the model, regions, time zone and budget; and what's sent to the model | @@ -2017,8 +2035,8 @@ core = AgentAnalyzer( timezone="Asia/Kolkata", # "last night" for the agent alone; set_timezone() sets it everywhere regions=["us-east-1", "eu-west-1"], # where it looks unless asked otherwise ("all" for every region) notes="orders-etl runs at 02:00 IST and loads the day's orders", # facts it's told about the account - budget={"tool_calls": 25, "seconds": 300, "usd": 1.0, "steps": 80, "log_days": 3}, - toolkits=["lambda"], # the services it has tools for: the default, and so far the only one + budget={"tool_calls": 25, "seconds": 300, "usd": 1.0, "steps": 80, "log_days": 3, "s3_keys": 100_000}, + toolkits=["lambda", "s3"], # the services it has tools for: both by default (s3_triggers needs both) ) agent = AgentView(core) @@ -2034,14 +2052,16 @@ core.tools() # the tools themselves: plain functions, for anot `chat_model=` takes any LangChain chat model instead of Azure OpenAI (for example `ChatBedrockConverse` from langchain-aws, for a model on Amazon Bedrock). The pure functions work without AWS or a model: `hide_secrets`, `asked_at`, `system_prompt`, `function_row`, `summarize_functions`, `summarize_health`, `summarize_errors`, -`summarize_runs`, `summarize_performance`, `summarize_detail`, `summarize_code`, `answer_findings`, `answer_next`, -`answer_cost` and `explain_model_error`. +`summarize_runs`, `summarize_performance`, `summarize_detail`, `summarize_code`, the S3 toolkit's `file_row`, +`summarize_buckets`, `summarize_bucket`, `summarize_listing`, `summarize_found`, `summarize_prefix`, `summarize_file`, +`count_arrivals` (which files a notification's filter and event types covered or missed, from a listing) and +`trigger_findings`, and `answer_findings`, `answer_next`, `answer_cost` and `explain_model_error`. Your own tools go in a `Toolkit` (from `aws_analyzer.agent`): a subclass with a name, a sentence for the model on what it covers, and `tools()` returning plain functions whose type hints and docstrings tell the model how to call them. -Pass it next to the built-in ones, `AgentView(toolkits=["lambda", Runbooks()])`, and its tools get the same budget, -error handling and report as the Lambda ones; the read-only guard covers the AWS clients of the analyzer it reads -through (`make_analyzer()`). The [agent guide](https://utkarsh5026.github.io/aws-analyzer/agent.html#toolkits) has an +Pass it next to the built-in ones, `AgentView(toolkits=["lambda", "s3", Runbooks()])`, and its tools get the same +budget, error handling and report as the built-in ones; the read-only guard covers every AWS client the analyzer it +reads through (`make_analyzer()`) makes from its session. The [agent guide](https://utkarsh5026.github.io/aws-analyzer/agent.html#toolkits) has an example. @@ -2049,18 +2069,27 @@ example.
Permissions and cost: the IAM permissions its tools use, and what a question costs -The tools read through `LambdaAnalyzer`, so they need the [Lambda permissions](#aws-lambda) above, all read-only: -`lambda:ListFunctions`, `lambda:GetFunction`, `lambda:ListEventSourceMappings`, `lambda:GetPolicy`, +The Lambda tools read through `LambdaAnalyzer`, so they need the [Lambda permissions](#aws-lambda) above, all +read-only: `lambda:ListFunctions`, `lambda:GetFunction`, `lambda:ListEventSourceMappings`, `lambda:GetPolicy`, `lambda:GetFunctionUrlConfig`, `lambda:GetFunctionEventInvokeConfig`, `lambda:ListAliases`, `lambda:ListVersionsByFunction`, `lambda:ListProvisionedConcurrencyConfigs`, `lambda:GetRuntimeManagementConfig`, `lambda:GetAccountSettings`, `cloudwatch:GetMetricData`, `logs:DescribeLogGroups`, `logs:DescribeLogStreams`, -`logs:FilterLogEvents` and, for `regions="all"`, `ec2:DescribeRegions`. Nothing else, and no permission to change anything. On the Azure side, an API key of the -resource, or an Entra ID identity with the Cognitive Services OpenAI User role on it. +`logs:FilterLogEvents` and, for `regions="all"`, `ec2:DescribeRegions`. The S3 tools read through `S3Analyzer`: +`s3:ListAllMyBuckets`, `s3:GetBucketLocation`, `s3:ListBucket`, `s3:GetObject` (for `file_info`'s HeadObject: the +headers, never the file), `s3:GetObjectTagging`, the bucket settings [S3's permissions](#amazon-s3) list for +`bucket_info` (`s3:GetBucketVersioning`, `s3:GetEncryptionConfiguration`, `s3:GetBucketPublicAccessBlock`, +`s3:GetBucketPolicyStatus`, `s3:GetBucketPolicy`, `s3:GetBucketOwnershipControls`, +`s3:GetBucketObjectLockConfiguration`, `s3:GetLifecycleConfiguration`, `s3:GetReplicationConfiguration`, +`s3:GetBucketLogging`, `s3:GetBucketTagging`, `s3:GetInventoryConfiguration`), `s3:GetBucketNotification`, +`s3:GetAccountPublicAccessBlock` and `cloudwatch:ListMetrics`. `s3_triggers` needs both sets. Nothing else, and no +permission to change anything. On the Azure side, an API key of the resource, or an Entra ID identity with the +Cognitive Services OpenAI User role on it. Each answer counts the model's input and output tokens. Their cost is shown when you pass `model_prices=` (Azure OpenAI's prices depend on the model, the region and the deployment type, so there are no built-in ones), and each -question's budget is checked against it. The CloudWatch metrics it reads cost $0.01 per 1,000 and are counted too; -reading logs and settings costs nothing. +question's budget is checked against it. The CloudWatch metrics it reads cost $0.01 per 1,000, and S3's requests +$0.005 per 1,000 listing pages (1,000 keys each) and $0.0004 per 1,000 others; the report counts both. Reading logs +and Lambda's settings costs nothing.
diff --git a/docs/agent.md b/docs/agent.md index 7143db7..1019990 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -1,13 +1,13 @@ --- title: Ask in Plain English (Agent) Guide -description: "How to ask questions about your AWS account in plain English from a SageMaker notebook with aws-analyzer's agent: an agent (an Azure OpenAI deployment, run by LangChain's deepagents) answers questions like which Lambda failed last night from read-only tools, says what it checked, and stays within a budget per question." +description: "How to ask questions about your AWS account in plain English from a SageMaker notebook with aws-analyzer's agent: an agent (an Azure OpenAI deployment, run by LangChain's deepagents) answers questions like which Lambda failed last night, or whether last night's uploads to a bucket were processed, from read-only tools, says what it checked, and stays within a budget per question." ---

aws-analyzer · agent/

# Ask about your AWS account in plain English -Ask "Which Lambda failed last night?" in a notebook cell and get the answer: which functions failed and how often, why, and the run that shows it. An agent works it out with the same read-only tools the reports use, and every answer says what it checked (the time window, in your time zone, and the regions), what it couldn't read, what it cost, and the commands to look closer. +Ask "Which Lambda failed last night?" in a notebook cell and get the answer: which functions failed and how often, why, and the run that shows it. Ask "Were last night's uploads to acme-raw processed?" and it checks the bucket, the files that arrived and the function they should have run. An agent works it out with the same read-only tools the reports use, and every answer says what it checked (the time window, in your time zone, and the regions), what it couldn't read, what it cost, and the commands to look closer. { .lede }
    @@ -15,10 +15,10 @@ Ask "Which Lambda failed last night?" in a notebook cell and get the answer: whi
  • Says what it checked
  • A budget per question
  • Follow-up questions
  • -
  • Lambda for now
  • +
  • Lambda and S3, joined
-Version 1 covers AWS Lambda. The model is your Azure OpenAI deployment, and the agent loop is LangChain's [deepagents](https://pypi.org/project/deepagents/). The examples use acme's functions from the [Lambda guide](lambda_functions.md): `orders-etl`, which loads each day's orders into the warehouse, and `report-api`. +It covers AWS Lambda and Amazon S3, and the bucket events that invoke functions. The model is your Azure OpenAI deployment, and the agent loop is LangChain's [deepagents](https://pypi.org/project/deepagents/). The examples use acme's functions from the [Lambda guide](lambda_functions.md): `orders-etl`, which loads each day's orders into the warehouse from the files that arrive in the `acme-raw` bucket, and `report-api`. { .muted } ## Set up in SageMaker { #setup } @@ -63,7 +63,7 @@ Version 1 covers AWS Lambda. The model is your Azure OpenAI deployment, and the !!! warning "" - **What leaves AWS.** Your question and what the tools return (function names, settings, counts, error messages and log lines, with anything that looks like a key or password hidden) are sent to your Azure OpenAI deployment. Environment variable values are never read. Use a deployment your organisation allows for that data. + **What leaves AWS.** Your question and what the tools return (function and bucket names, settings, counts, file names, error messages and log lines, with anything that looks like a key or password hidden) are sent to your Azure OpenAI deployment. Environment variable values and what's inside your files are never read. Use a deployment your organisation allows for that data. ## Five-minute tour { #tour } @@ -71,12 +71,14 @@ Version 1 covers AWS Lambda. The model is your Azure OpenAI deployment, and the agent.ask("Which Lambda failed last night?") agent.ask("Why did orders-etl fail?") # a follow-up: it remembers the conversation agent.ask("Show me one of those runs") # ...and again +agent.ask("Were last night's uploads to acme-raw processed?", new=True) # S3 and Lambda together +agent.ask("Which buckets are public, and what do they cost?", new=True) agent.ask("Which functions run close to their timeout this week?", new=True) # a new conversation agent.tools() # what it can call, its model and its budget agent.transcript() # every question and answer so far, with their cost ``` -Each `ask()` shows a report: the answer first, then **What it checked**, a line per tool call with the window and regions it covered and what it found, then what it couldn't read or was refused, the AWS reads it made, the tokens and their cost, and the `LambdaView` commands that show the same thing as a full report, arguments filled in. +Each `ask()` shows a report: the answer first, then **What it checked**, a line per tool call with the window and regions it covered (and for S3, the keys it read) and what it found, then what it couldn't read or was refused, the AWS reads it made, the tokens and their cost, and the `LambdaView` and `S3View` commands that show the same thing as a full report, arguments filled in. ## How it answers { #how-it-works } @@ -87,11 +89,15 @@ The model gets a set of tools and decides which to call. For "Which Lambda faile 3. Calls `function_errors` for the functions that failed, which reads their logs and groups the errors by cause. 4. Answers, and ends with the window and regions it checked. +For "Were last night's uploads to acme-raw processed?" it calls `s3_triggers` for the bucket and the window. That reads the bucket's event notifications (which functions it invokes, on which events, for which keys), lists the files written in the window, checks that the function is there and that its resource policy lets the bucket invoke it, and reads the function's runs and failures from CloudWatch for the same window. The findings name what broke the link: files whose ending is `.CSV` when the filter says `.csv` (S3 compares case-sensitively), files uploaded in parts when the notification fires only on PUT uploads, a function that isn't there, a permission that's missing (with the `aws lambda add-permission` command), or no file arriving at all. Then `function_errors` reads why the runs that did happen failed. + Follow-ups continue the conversation, so "why did it fail?" knows what "it" is. `new=True` or `agent.new_conversation()` starts a fresh one. The kernel's stop button stops a question, and the report shows what it found until then. ## What it can call { #tools } -Seven tools, each a building block over `LambdaAnalyzer` rather than one tool per question. They take parameters (a time window, a name glob, regions, a search), so a few of them answer many questions, and each returns a compact summary with what it covered instead of raw AWS data. +Fourteen tools in two toolkits, each a building block over `LambdaAnalyzer` or `S3Analyzer` rather than one tool per question. They take parameters (a time window, a name glob, regions, a location, a search), so a few of them answer many questions, and each returns a compact summary with what it covered instead of raw AWS data. + +**Lambda** | Tool | What it returns | |---|---| @@ -103,11 +109,30 @@ Seven tools, each a building block over `LambdaAnalyzer` rather than one tool pe | `function_details(function, region)` | Settings, triggers, aliases, its last 30 days, log retention, the estimated monthly cost by part, and every finding | | `function_code(function, region)` | What a Python function's code calls, read with `ast` and never run: AWS operations, environment variables by name, event fields | +**S3**, which never reads what's inside a file: + +| Tool | What it returns | +|---|---| +| `find_buckets(match, sizes)` | The buckets and their regions; `sizes=True` adds each one's object count and size from CloudWatch, biggest first | +| `bucket_details(bucket)` | Public access, encryption, versioning, lifecycle rules in plain words, replication, logging, tags, its event notifications, size and monthly cost, and its findings | +| `list_folder(location)` | One level of a folder, like `aws s3 ls`: its subfolders, and its files newest first | +| `find_files(location, match, since, until, min_size, max_size, limit)` | The files under a location that match a name glob, a time window or a size: how many, how big, the newest | +| `folder_summary(location)` | What a location holds by file type, storage class and folder, the largest files, the monthly cost and findings | +| `file_info(location)` | One file's headers: size, type, storage class (Glacier needs a restore first), encryption (a KMS key means readers need `kms:Decrypt`), metadata and tags | + +**S3 and Lambda together:** + +| Tool | What it returns | +|---|---| +| `s3_triggers(bucket, function, since, until, region)` | Which bucket events invoke which functions, on which keys. Give a bucket, a function, or both. For each link: whether the function is there and lets the bucket invoke it, how many files its filter covers arrived in the window and how many of them ran it (and which were missed, by the case of their ending or by how they were uploaded), and the function's runs, failures and throttles in that window | + +`s3_triggers` is there when the agent has both toolkits, as it does by default. From a function, it finds the buckets its resource policy names; when the policy lets any bucket invoke it (or none), it looks through the buckets in the function's region, up to 50. + `agent.tools()` lists them with the AWS reads each makes. `agent.core.tools()` returns the functions themselves, with type hints and docstrings, so another agent framework can use them too. ## Safe and bounded { #safety } -- **It only reads.** The tools call the analyzers' read methods. A guard on every AWS client they use refuses any operation that isn't a Get, List, Describe or Filter call before its request is built, so even a mistake in a tool can't change your account. There's no shell or code execution: deepagents' `execute` tool and its general-purpose subagent are turned off, and its file tools only keep notes in memory, never on disk. +- **It only reads.** The tools call the analyzers' read methods. A guard on every AWS client they use refuses any operation that isn't a Get, List, Describe, Head or Filter call before its request is built, so even a mistake in a tool can't change your account. The S3 tools read names, sizes, dates and settings, never what's inside a file. There's no shell or code execution: deepagents' `execute` tool and its general-purpose subagent are turned off, and its file tools only keep notes in memory, never on disk. - **It says what it checked.** Every tool result carries its window, its regions and whether it was cut short, and the agent is told to say so in the answer. A permission the role lacks becomes part of the answer (`AccessDeniedException; needs logs:FilterLogEvents`), not an error, and the report lists it. - **Log lines are data.** What the tools read can contain text that looks like instructions ("ignore your instructions..."). The agent is told never to follow it, and the tools hide anything that looks like a key, token or password before the model sees it. @@ -122,12 +147,13 @@ Each question has limits. Past one, the tools stop running and tell the model to | `usd` | 1.00 | The same, when `model_prices=` says what its tokens cost | | `steps` | 80 | The agent stops (LangGraph's recursion limit) | | `log_days` | 3 | A longer log read is refused; CloudWatch's counts still cover the window | +| `s3_keys` | 100,000 | An S3 listing stops there and says so: its counts cover the keys it read | ```python agent = AgentView(budget={"tool_calls": 50, "log_days": 7}) ``` -Log reads are limited because searching weeks of a busy function's logs is slow. For long windows the agent uses CloudWatch's counts, which cover months in seconds, and reads logs only for a short window around what it found. +Log reads are limited because searching weeks of a busy function's logs is slow. For long windows the agent uses CloudWatch's counts, which cover months in seconds, and reads logs only for a short window around what it found. S3 listings are limited for the same reason: S3 lists 1,000 keys per request, in key order, so the agent is told to give `find_files` and `folder_summary` the narrowest prefix it can (a day's folder rather than the bucket). ## Use the data in Python { #python } @@ -155,16 +181,17 @@ core = AgentAnalyzer( timezone="Asia/Kolkata", # for the agent alone; set_timezone() sets it everywhere regions=["us-east-1", "eu-west-1"], # where it looks unless asked otherwise; "all" for every region notes="orders-etl runs at 02:00 IST and loads the day's orders", # facts it's told about your account - budget={"tool_calls": 25, "log_days": 3}, + budget={"tool_calls": 25, "log_days": 3, "s3_keys": 100_000}, + toolkits=["lambda", "s3"], # the default; ["s3"] for S3 alone ) agent = AgentView(core) ``` -`chat_model=` takes any LangChain chat model instead of Azure OpenAI: for a model on Amazon Bedrock, `ChatBedrockConverse` from langchain-aws. The pure functions work without AWS or a model: `hide_secrets`, `asked_at`, `system_prompt`, `summarize_health`, `summarize_errors`, `summarize_runs`, `summarize_performance`, `summarize_detail`, `summarize_code`, `answer_findings` and `explain_model_error`. +`chat_model=` takes any LangChain chat model instead of Azure OpenAI: for a model on Amazon Bedrock, `ChatBedrockConverse` from langchain-aws. `lambda_core=` and `s3_core=` take a `LambdaAnalyzer` and an `S3Analyzer` of your own (with their own `prices=`). The pure functions work without AWS or a model: `hide_secrets`, `asked_at`, `system_prompt`, `summarize_health`, `summarize_errors`, `summarize_runs`, `summarize_performance`, `summarize_detail`, `summarize_code`, the S3 toolkit's `summarize_bucket`, `summarize_found`, `summarize_prefix`, `summarize_file`, `count_arrivals` (from a listing: which files a notification's filter and event types covered or missed) and `trigger_findings`, `answer_findings` and `explain_model_error`. ### Your own tools { #toolkits } -The agent's tools come in toolkits, one per AWS service: `toolkits=["lambda"]` is the default. A toolkit of your own adds tools beside them. Give it a name, a sentence for the model on what it covers, and `tools()`: plain functions whose type hints and docstrings tell the model how to call them, each returning a dict. +The agent's tools come in toolkits, one per AWS service: `toolkits=["lambda", "s3"]` is the default. A toolkit of your own adds tools beside them. Give it a name, a sentence for the model on what it covers, and `tools()`: plain functions whose type hints and docstrings tell the model how to call them, each returning a dict. ```python from aws_analyzer.agent import AgentView, Toolkit @@ -185,21 +212,21 @@ class Runbooks(Toolkit): return [runbook] -agent = AgentView(toolkits=["lambda", Runbooks()]) +agent = AgentView(toolkits=["lambda", "s3", Runbooks()]) agent.ask("orders-etl failed last night: what should I do?") ``` -Its tools get the same budget, error handling and report as the Lambda ones, and `tools()` lists them. When it reads AWS, make its clients in the analyzer `make_analyzer()` returns: the read-only guard covers that analyzer's `client` and the clients it keeps in `_clients`. +Its tools get the same budget, error handling and report as the built-in ones, and `tools()` lists them. When it reads AWS, make its clients in the analyzer `make_analyzer()` returns, from the session it's given: the read-only guard covers every client that analyzer makes from its session, and the clients it already holds. A tool that joins your service to another reads that one through the agent's own toolkit for it, `self.peer("lambda")`, which is `None` when the agent hasn't got it. ## Cost { #cost } Each answer counts the model's input and output tokens. Azure OpenAI's prices depend on the model, the region and the deployment type, so there are none built in: pass `model_prices=` and each answer shows its estimated cost, and the budget's `usd` limit applies. Without it the cost shows as unknown, never a guess. -On the AWS side, the CloudWatch metrics it reads cost $0.01 per 1,000, and the report says how many it read; reading logs, settings and code costs nothing. `function_health` reads 7 metrics per function, so a question about 100 functions reads about 700: under a cent. +On the AWS side, the CloudWatch metrics it reads cost $0.01 per 1,000, and S3's requests $0.005 per 1,000 listing pages (each lists up to 1,000 keys) and $0.0004 per 1,000 others; the report says how many of each it made and what they cost. Reading logs, Lambda's settings and code costs nothing. `function_health` reads 7 metrics per function, so a question about 100 functions reads about 700: under a cent. A `find_files` over 100,000 keys makes 100 listing requests: $0.0005. ## Permissions { #permissions } -The tools read through `LambdaAnalyzer`, so the role needs the [Lambda guide's read-only policy](lambda_functions.md#permissions), and nothing more: no Bedrock and no permission to change anything. A permission that's missing shows up in the answer as what couldn't be read, and the rest of the answer still stands. +The Lambda tools read through `LambdaAnalyzer`, so the role needs the [Lambda guide's read-only policy](lambda_functions.md#permissions). The S3 tools read through `S3Analyzer`, so they need the [S3 guide's read-only policy](s3.md#permissions) (with `s3:GetBucketNotification`, which `bucket_details` and `s3_triggers` read the bucket's event notifications with). `s3_triggers` needs both. Nothing more: no Bedrock and no permission to change anything. A permission that's missing shows up in the answer as what couldn't be read, and the rest of the answer still stands. On the Azure side, an API key of the Azure OpenAI resource, or a Microsoft Entra ID identity with the **Cognitive Services OpenAI User** role on it. @@ -221,6 +248,10 @@ On the Azure side, an API key of the Azure OpenAI resource, or a Microsoft Entra Log lines can set off Azure's content filter. Ask about a narrower window, or adjust the deployment's content filter. +??? question "`s3_triggers` says the function doesn't let the bucket invoke it" + + S3 can only invoke a function whose resource-based policy allows `s3.amazonaws.com` for that bucket. The finding gives the `aws lambda add-permission ...` command to run; the agent never runs it. If the permission is there and the finding still says so, the role may lack `lambda:GetPolicy`: the answer then lists it under what couldn't be read. + ??? question "It answered, but says it was stopped at the budget" The question needed more tool calls, time or log days than the budget allows. Ask a narrower question (one function, a shorter window), or raise the limit it names: `AgentView(budget={"tool_calls": 50})`. @@ -231,7 +262,7 @@ On the Azure side, an API key of the Azure OpenAI resource, or a Microsoft Entra | Command | What it shows | |---|---| -| `ask(question, new=False)` | The answer first, then what it checked: each tool call with its window, regions and what it found, what it couldn't read or was refused, the AWS reads, tokens and cost, and the `LambdaView` commands to look closer | +| `ask(question, new=False)` | The answer first, then what it checked: each tool call with its window, regions and what it found, what it couldn't read or was refused, the AWS reads, tokens and cost, and the `LambdaView` and `S3View` commands to look closer | | `new_conversation()` | Starts a new conversation: the next question won't see the earlier ones | | `transcript()` | Every question asked so far and its answer, with the tool calls, tokens and cost of each, and the total | | `tools()` | Each tool and the AWS reads it makes; the model, regions, time zone and budget; and what's sent to the model | diff --git a/docs/s3.md b/docs/s3.md index 4c05038..ea7817e 100644 --- a/docs/s3.md +++ b/docs/s3.md @@ -640,6 +640,7 @@ for obj in s3.iter_objects("s3://acme-ml-data/raw/", limit=5000): # stream a l cfg = s3.bucket_config("acme-ml-data") # BucketConfig cfg.versioning, cfg.encryption, cfg.lifecycle_rules reports = s3.bucket_reports(match="acme-*") # config + CloudWatch size for many buckets +s3.notifications("acme-ml-data") # [EventNotification]: which events go to which function, queue or topic for st in explain_policy(s3.bucket_policy("acme-ml-data"), s3.account_id()): print(st.effect, st.who, st.actions, st.resources, st.conditions) ``` @@ -656,7 +657,7 @@ summarize_objects(rows, "s3://acme-ml-data/raw/").monthly_cost simulate_lifecycle_objects(rows, "s3://acme-ml-data/raw/", move_after=30, to="GLACIER_IR").monthly_savings ``` -Also pure: `build_folder_tree`, `make_filter`, `find_duplicate_groups`, the duplicate finder's steps (`files_to_hash`, `group_duplicates`, `duplicate_folders`, `duplicate_findings`), `zip_checks`, `zip_findings`, `compare_objects`, `summary_findings`, `bucket_findings`, `policy_findings`, `object_monthly_cost`, `cloudwatch_cost`, `parse_avro` and `sniff_format`. +Also pure: `build_folder_tree`, `make_filter`, `find_duplicate_groups`, the duplicate finder's steps (`files_to_hash`, `group_duplicates`, `duplicate_folders`, `duplicate_findings`), `zip_checks`, `zip_findings`, `compare_objects`, `summary_findings`, `bucket_findings`, `policy_findings`, `parse_notifications`, `object_monthly_cost`, `cloudwatch_cost`, `parse_avro` and `sniff_format`. ## Build your own analysis { #build } @@ -912,7 +913,7 @@ Everything is read-only. Anything the notebook's role can't read shows up as a n "s3:GetBucketPolicy", "s3:GetBucketPolicyStatus", "s3:GetBucketOwnershipControls", "s3:GetBucketObjectLockConfiguration", "s3:GetLifecycleConfiguration", "s3:GetReplicationConfiguration", "s3:GetBucketLogging", "s3:GetBucketTagging", "s3:GetInventoryConfiguration", - "s3:GetAccountPublicAccessBlock" + "s3:GetBucketNotification", "s3:GetAccountPublicAccessBlock" ], "Resource": "*" }, diff --git a/src/aws_analyzer/agent/__init__.py b/src/aws_analyzer/agent/__init__.py index 6d8526d..474e7c3 100644 --- a/src/aws_analyzer/agent/__init__.py +++ b/src/aws_analyzer/agent/__init__.py @@ -6,17 +6,19 @@ A question like "Which Lambda failed last night?" goes to a model (an Azure OpenAI deployment for now), which answers it by calling read-only tools built on the package's analyzers, and every answer says what it checked. The agent -loop is LangChain's deepagents. It covers AWS Lambda for now: which functions failed, threw errors, ran slow or near -their timeout, their logs run by run, their settings and cost, and what their code calls. Nothing it does changes -anything in AWS: the tools only read, and a guard on every AWS client the tools use refuses any call that isn't a -read, whatever asks for it. +loop is LangChain's deepagents. It covers AWS Lambda and Amazon S3 for now: which functions failed, threw errors, ran +slow or near their timeout, their logs run by run, their settings and cost, and what their code calls; which buckets +there are, how they're set up, what they hold and which files arrived when; and, joining the two, which bucket events +invoke which functions and whether the files that arrived ran them. Nothing it does changes anything in AWS: the +tools only read, and a guard on every AWS client the tools use refuses any call that isn't a read, whatever asks for +it. Requirements: boto3 (required). deepagents and langchain-openai for the agent itself (the [agent] extra), which need Python 3.11 or newer; IPython only for rich HTML output. -What it sends to the model: your question, and what its tools return (function names, settings, counts, error messages and -log lines, with anything that looks like a key or password hidden). Environment variable values are never -read. That goes to your Azure OpenAI deployment, outside AWS: use a deployment your organisation allows for that data. +What it sends to the model: your question, and what its tools return (function and bucket names, settings, counts, file +names, error messages and log lines, with anything that looks like a key or password hidden). Environment variable +values and what's inside files are never read. That goes to your Azure OpenAI deployment, outside AWS: use a deployment your organisation allows for that data. Quick start ----------- @@ -26,6 +28,7 @@ agent = AgentView() # or AgentView(endpoint=..., deployment=..., api_key=...) agent.ask("Which Lambda failed last night?") agent.ask("Why did orders-etl fail?") # a follow-up: it remembers the conversation + agent.ask("Were last night's uploads to acme-raw processed?") # S3 and Lambda together agent.new_conversation() # forget it agent.tools() # what it can call, its model and its budget agent.transcript() # every question and answer so far, with their cost @@ -39,7 +42,8 @@ toolkits/ What the agent can read: a Toolkit per AWS service (base.py says what one gives), each with its tools, the AWS reads they make, its part of the system prompt, and the pure summarize_* functions that turn an analyzer's results into what the model reads. lambda_tools.py is - Lambda's; TOOLKITS (toolkits/__init__.py) names them. + Lambda's and s3_tools.py S3's, with s3_triggers, which joins the two when the agent has both; + TOOLKITS (toolkits/__init__.py) names them. runtime.py How any tool runs: the budget checked first, the call recorded, errors turned into its result. guard.py The read-only guard every toolkit's AWS clients go through. budget.py How far a question may go (Budget) and the question being answered (_Run). @@ -62,6 +66,22 @@ from .prompt import asked_at, system_prompt from .results import hide_secrets from .toolkits import DEFAULT_TOOLKITS, TOOLKITS, Toolkit +from .toolkits.s3_tools import ( + S3_TOOL_READS, + Arrivals, + S3Toolkit, + TriggerLink, + count_arrivals, + file_row, + summarize_bucket, + summarize_buckets, + summarize_file, + summarize_found, + summarize_listing, + summarize_prefix, + summarize_triggers, + trigger_findings, +) from .toolkits.lambda_tools import ( TOOL_READS, LambdaToolkit, @@ -89,6 +109,10 @@ "DEFAULT_TOOLKITS", "LambdaToolkit", "TOOL_READS", + "S3Toolkit", + "S3_TOOL_READS", + "Arrivals", + "TriggerLink", "AZURE_API_VERSION", "hide_secrets", "asked_at", @@ -105,4 +129,14 @@ "summarize_performance", "summarize_detail", "summarize_code", + "file_row", + "summarize_buckets", + "summarize_bucket", + "summarize_listing", + "summarize_found", + "summarize_prefix", + "summarize_file", + "count_arrivals", + "trigger_findings", + "summarize_triggers", ] diff --git a/src/aws_analyzer/agent/analyzer.py b/src/aws_analyzer/agent/analyzer.py index d544bf9..f68e594 100644 --- a/src/aws_analyzer/agent/analyzer.py +++ b/src/aws_analyzer/agent/analyzer.py @@ -22,6 +22,7 @@ if TYPE_CHECKING: from ..lambda_functions import LambdaAnalyzer + from ..s3 import S3Analyzer class AgentAnalyzer(_BaseAnalyzer): @@ -35,10 +36,11 @@ class AgentAnalyzer(_BaseAnalyzer): Bedrock, a fake one in tests). model_prices= {'gpt-4o': (2.50, 10.00)} (USD per 1M input and output tokens) puts a price on each answer; without it the cost is unknown and only the tokens are counted. - What it reads: toolkits= picks the services it has tools for (names like 'lambda', or Toolkit objects; the - default is DEFAULT_TOOLKITS: Lambda). The Lambda toolkit reads through LambdaAnalyzer: lambda_core= to pass your - own, lambda_clients= its clients (for tests), prices= its prices. It looks in `region` (the session's) or - `regions` (a list, or 'all'). The user's time zone, so "last night" means their night, is set_timezone()'s (or + What it reads: toolkits= picks the services it has tools for (names like 'lambda' and 's3', or Toolkit objects; + the default is DEFAULT_TOOLKITS: Lambda and S3, with s3_triggers joining them). The Lambda toolkit reads through + LambdaAnalyzer: lambda_core= to pass your own, lambda_clients= its clients (for tests), prices= its prices. The + S3 toolkit reads through S3Analyzer: s3_core= to pass your own (with its prices=). It looks in `region` (the + session's) or `regions` (a list, or 'all'); buckets are read wherever they are. The user's time zone, so "last night" means their night, is set_timezone()'s (or AWS_ANALYZER_TZ's), the zone every report shows times in; `timezone` ('Asia/Kolkata', 'kolkata', '+05:30') sets one for the agent alone. `notes` are facts about the account the agent is told ("orders-etl runs at 02:00 IST"). `budget` caps each question (Budget, or a dict of its fields). @@ -68,6 +70,7 @@ def __init__( lambda_core: LambdaAnalyzer | None = None, lambda_clients: dict[str, Any] | None = None, prices: dict[str, float] | None = None, + s3_core: S3Analyzer | None = None, ): super().__init__(session, region=region, profile=profile, prices=model_prices) self.deployment, self.endpoint, self.api_version = azure_settings(deployment, endpoint, api_version) @@ -86,7 +89,8 @@ def __init__( self.max_workers = 8 self._guard = _Guard() self.toolkits: list[Toolkit] = [toolkit.bind(self) for toolkit in make_toolkits( - toolkits, {"lambda": {"analyzer": lambda_core, "clients": lambda_clients, "prices": prices}})] + toolkits, {"lambda": {"analyzer": lambda_core, "clients": lambda_clients, "prices": prices}, + "s3": {"analyzer": s3_core}})] self._graph: Any = None self._run: _Run | None = None self._tools: list[Callable[..., dict[str, Any]]] | None = None @@ -94,7 +98,7 @@ def __init__( self.history: list[AgentAnswer] = [] # every question asked, in order def toolkit(self, name: str) -> Toolkit: - """One of the agent's toolkits, by its name ('lambda').""" + """One of the agent's toolkits, by its name ('lambda', 's3').""" for toolkit in self.toolkits: if toolkit.name == name: return toolkit @@ -194,7 +198,7 @@ def ask( answer.aws_calls = dict(self._guard.counts) answer.blocked = list(self._guard.blocked) answer.metrics_read = run.metrics_read - answer.aws_cost = run.aws_cost + answer.aws_cost = run.aws_cost + sum(t.request_cost(answer.aws_calls) for t in self.toolkits) answer.cost = answer_cost(answer.model, answer.input_tokens, answer.output_tokens, self.prices) answer.seconds = run.elapsed if answer.stopped is None: diff --git a/src/aws_analyzer/agent/answers.py b/src/aws_analyzer/agent/answers.py index a250a09..2ff8a6b 100644 --- a/src/aws_analyzer/agent/answers.py +++ b/src/aws_analyzer/agent/answers.py @@ -26,6 +26,7 @@ class ToolCall: error: str | None = None # why it failed: 'AccessDeniedException; needs logs:FilterLogEvents' refused: str | None = None # why it wasn't run: 'tool_calls', 'time', 'cost', 'log_window', 'stopped' next: list[tuple[str, str]] = field(default_factory=list) # View calls that show more, and what they show + first: str | None = "function" # the tool's first parameter when it has no default: written without its name @property def ok(self) -> bool: @@ -35,7 +36,7 @@ def ok(self) -> bool: def call(self) -> str: """The call as the model made it: "function_errors('orders-etl', since='2026-10-15T12:30Z')".""" args = {k: v for k, v in self.args.items() if v not in ("", None)} - first = args.pop("function", None) + first = args.pop(self.first, None) if self.first else None return _call(self.name, first, **args) if first is not None else _call(self.name, **args) @@ -49,7 +50,7 @@ class AgentAnswer: aws_calls: dict[str, int] = field(default_factory=dict) # 'logs:FilterLogEvents' -> 12 blocked: list[str] = field(default_factory=list) # AWS calls the read-only guard refused metrics_read: int = 0 # CloudWatch metrics requested (GetMetricData bills per 1,000) - aws_cost: float = 0.0 # USD for those metrics ($0.01 per 1,000 at list price); its other reads are free + aws_cost: float = 0.0 # USD for those metrics ($0.01 per 1,000 at list price) and S3's requests; the rest are free model: str = "" # the deployment or model that answered model_calls: int = 0 input_tokens: int = 0 @@ -99,7 +100,12 @@ def answer_findings(answer: AgentAnswer, budget: Budget | None = None) -> list[t f"{budget.log_days:g}-day limit for a log read, which keeps questions fast. " "CloudWatch's counts cover longer windows; to read the logs too, raise the limit: " f"AgentView(budget={{'log_days': {days}}}).")) - if call.coverage.get("cut_short"): + if call.coverage.get("cut_short") and call.coverage.get("scanned"): + keys = int(call.coverage["scanned"]) + found.append(("info", f"{call.call} stopped after {keys:,} S3 keys (the budget's s3_keys), in key order: " + "its counts cover those keys only. Ask about a narrower prefix, or raise the limit: " + f"AgentView(budget={{'s3_keys': {max(keys, budget.s3_keys) * 5}}}).")) + elif call.coverage.get("cut_short"): reach = call.coverage.get("covered_from") found.append(("info", f"{call.name} for {call.args.get('function') or 'the functions'} stopped at its " "line limit" + (f": it covers {_fmt_dt(reach, zone=True)} to the end of the window" @@ -107,7 +113,7 @@ def answer_findings(answer: AgentAnswer, budget: Budget | None = None) -> list[t + ". The newest lines were kept.")) if not answer.calls and not answer.error and answer.stopped is None: found.append(("info", "It answered without calling a tool, so nothing in the answer comes from your account. " - "Ask about your functions, or a time window.")) + "Ask about your functions or buckets, or a time window.")) return found diff --git a/src/aws_analyzer/agent/budget.py b/src/aws_analyzer/agent/budget.py index b0337e9..c46bc51 100644 --- a/src/aws_analyzer/agent/budget.py +++ b/src/aws_analyzer/agent/budget.py @@ -25,6 +25,7 @@ class Budget: usd: float | None = 1.00 # estimated model cost per question (only when the model's prices are known) steps: int = 80 # LangGraph's recursion limit: model calls, tool calls and the harness's own steps together log_days: float = 3.0 # the longest window a log read (errors, logs, performance) may cover + s3_keys: int = 100_000 # the most keys one S3 listing may read (find_files, folder_summary, s3_triggers) @classmethod def of(cls, value: Budget | dict[str, Any] | None) -> Budget: diff --git a/src/aws_analyzer/agent/guard.py b/src/aws_analyzer/agent/guard.py index 0e518c5..a053781 100644 --- a/src/aws_analyzer/agent/guard.py +++ b/src/aws_analyzer/agent/guard.py @@ -8,12 +8,41 @@ from collections import Counter from typing import Any -READ_PREFIXES = ("Get", "List", "Describe", "Filter") # the AWS operations the tools may call: reads only -# (FilterLogEvents reads a log group). The guard refuses every other one before its request is built. +READ_PREFIXES = ("Get", "List", "Describe", "Head", "Filter") # the AWS operations the tools may call: reads only +# (FilterLogEvents reads a log group; HeadObject and HeadBucket read an object's or a bucket's headers). The guard +# refuses every other one before its request is built. class ReadOnlyError(PermissionError): - """An AWS call the read-only guard refused: it isn't a Get, List, Describe or Filter call.""" + """An AWS call the read-only guard refused: it isn't a Get, List, Describe, Head or Filter call.""" + + +def _is_client(value: Any) -> bool: + """Whether `value` is a boto3 client (or a stand-in for one, as tests use): it has meta.events to hook.""" + return getattr(getattr(value, "meta", None), "events", None) is not None + + +class _GuardedSession: + """A boto3 session whose every client goes through the guard. An analyzer the guard attaches to reads AWS through + one in place of its own session, so a client it makes later (in another region, for another service, in any + method) is hooked as it's made. Everything else is the session's own.""" + + def __init__(self, session: Any, guard: _Guard): + self._session, self._guard = session, guard + + def client(self, *args: Any, **kwargs: Any) -> Any: + return self._guard.watch(self._session.client(*args, **kwargs)) + + def resource(self, *args: Any, **kwargs: Any) -> Any: + resource = self._session.resource(*args, **kwargs) + self._guard.watch(resource.meta.client) + return resource + + def __getattr__(self, name: str) -> Any: + return getattr(self._session, name) + + def __repr__(self) -> str: + return f"" class _Guard: @@ -22,7 +51,7 @@ class _Guard: def __init__(self) -> None: self._lock = threading.Lock() - self._watched: set[int] = set() + self._hook = f"aws-analyzer-read-only-{id(self)}" # each client's emitter keeps it once, however often asked self.counts: Counter[str] = Counter() self.blocked: list[str] = [] @@ -43,17 +72,20 @@ def _check(self, model: Any = None, **_: Any) -> None: self.counts[op] += 1 def watch(self, client: Any) -> Any: - """Hooks one boto3 client (once) and returns it.""" + """Hooks one boto3 client (once: its event emitter keeps the hook's unique_id, so asking again does nothing) + and returns it.""" events = getattr(getattr(client, "meta", None), "events", None) - if events is not None and id(client) not in self._watched: - events.register_first("before-parameter-build.*.*", self._check) - self._watched.add(id(client)) + if events is None: + return client + with self._lock: # tools run side by side: register each hook once + events.register_first("before-parameter-build.*.*", self._check, unique_id=self._hook) return client def attach(self, core: Any, factory: str | None = None) -> Any: - """Hooks the clients an analyzer (`core`) has made: its main `client` when it's set in __init__, the ones in - its `_clients`, and, when it makes clients through a method (`factory`, LambdaAnalyzer's `_service`), every - one it makes from now on. Returns `core`.""" + """Hooks an analyzer (`core`): its session becomes a _GuardedSession, so every client it makes from now on is + hooked; the clients it already has are hooked (its main `client` when it's set in __init__, and the ones it + keeps in dicts, like `_clients`); and, when it makes clients through a method (`factory`, LambdaAnalyzer's + `_service`), every client that returns, which covers clients passed in (clients=) too. Returns `core`.""" if factory and not getattr(core, "_agent_guard", None): make = getattr(core, factory) @@ -62,8 +94,16 @@ def made(*args: Any, **kwargs: Any) -> Any: setattr(core, factory, made) core._agent_guard = self + session = getattr(core, "session", None) + if session is not None and not isinstance(session, _GuardedSession): + core.session = _GuardedSession(session, self) attributes = getattr(core, "__dict__", {}) # not a `client` property: reading one could make a client - for client in [attributes.get("client"), *getattr(core, "_clients", {}).values()]: - if client is not None: - self.watch(client) + for value in list(attributes.values()): + try: + found = list(value.values()) if isinstance(value, dict) else [value] + except RuntimeError: # a tool running beside this one added a client: it was made hooked + continue + for client in found: + if _is_client(client): + self.watch(client) return core diff --git a/src/aws_analyzer/agent/runtime.py b/src/aws_analyzer/agent/runtime.py index c1d8811..a82353c 100644 --- a/src/aws_analyzer/agent/runtime.py +++ b/src/aws_analyzer/agent/runtime.py @@ -21,7 +21,7 @@ if TYPE_CHECKING: from .toolkits import Toolkit -COVERAGE = ("window", "regions", "region", "cut_short", "covered_from") # a result's keys the report shows +COVERAGE = ("window", "regions", "region", "cut_short", "covered_from", "scanned") # a result's keys the report shows def as_tool(fn: Callable[..., dict[str, Any]], toolkit: Toolkit, @@ -38,7 +38,9 @@ def tool(*args: Any, **kwargs: Any) -> dict[str, Any]: arguments = dict(inspect.signature(fn).bind_partial(*args, **kwargs).arguments) except TypeError: arguments = dict(kwargs) - call = ToolCall(fn.__name__, arguments) + defaults = _defaults(fn) # LangChain passes every default: keep what the model asked for + arguments = {k: v for k, v in arguments.items() if k not in defaults or v != defaults[k]} + call = ToolCall(fn.__name__, arguments, first=_first_parameter(fn)) started = time.monotonic() with run.lock: run.calls.append(call) @@ -82,3 +84,22 @@ def tool(*args: Any, **kwargs: Any) -> dict[str, Any]: return _plain(result) return tool + + +def _defaults(fn: Callable[..., Any]) -> dict[str, Any]: + """A tool's parameters that have a default, and their defaults.""" + try: + params = inspect.signature(fn).parameters.values() + except (TypeError, ValueError): + return {} + return {p.name: p.default for p in params if p.default is not inspect.Parameter.empty} + + +def _first_parameter(fn: Callable[..., Any]) -> str | None: + """The name of a tool's first parameter when it has no default ('function', 'location'): the report writes it + without its name, as the call reads in Python.""" + try: + params = list(inspect.signature(fn).parameters.values()) + except (TypeError, ValueError): + return None + return params[0].name if params and params[0].default is inspect.Parameter.empty else None diff --git a/src/aws_analyzer/agent/toolkits/__init__.py b/src/aws_analyzer/agent/toolkits/__init__.py index 65fa562..b26e105 100644 --- a/src/aws_analyzer/agent/toolkits/__init__.py +++ b/src/aws_analyzer/agent/toolkits/__init__.py @@ -1,10 +1,12 @@ -"""The agent's toolkits: one module per AWS service (lambda_tools.py), each a Toolkit subclass (base.py) with the tools -the agent can call about that service. TOOLKITS names the built-in ones; AgentAnalyzer(toolkits=[...]) picks which +"""The agent's toolkits: one module per AWS service (lambda_tools.py, s3_tools.py), each a Toolkit subclass (base.py) +with the tools the agent can call about that service. TOOLKITS names the built-in ones; AgentAnalyzer(toolkits=[...]) picks which the agent gets, by name or as Toolkit objects (yours too), and DEFAULT_TOOLKITS is what it gets without that. Adding a service: a module here (`_tools.py`) with a Toolkit subclass that reads through the service's analyzer, its name in TOOLKITS (and in DEFAULT_TOOLKITS when every agent should have it), the analyzer in -COMPANIONS["agent"] in .claude/skills/check/rules.py, and its tools' reads in README's IAM permissions.""" +COMPANIONS["agent"] in .claude/skills/check/rules.py, and its tools' reads in README's IAM permissions. A tool that +joins two services (S3's s3_triggers, which reads Lambda too) goes in one of their toolkits, which offers it when the +agent has the other (Toolkit.peer()).""" from __future__ import annotations @@ -13,14 +15,15 @@ from .base import Toolkit from .lambda_tools import LambdaToolkit +from .s3_tools import S3Toolkit -TOOLKITS: dict[str, type[Toolkit]] = {"lambda": LambdaToolkit} # name -> the built-in toolkit -DEFAULT_TOOLKITS = ("lambda",) # what an agent gets unless toolkits= says otherwise +TOOLKITS: dict[str, type[Toolkit]] = {"lambda": LambdaToolkit, "s3": S3Toolkit} # name -> the built-in toolkit +DEFAULT_TOOLKITS = ("lambda", "s3") # what an agent gets unless toolkits= says otherwise def make_toolkit(spec: str | Toolkit | type[Toolkit], **settings: Any) -> Toolkit: - """A Toolkit from its name in TOOLKITS ('lambda'), a Toolkit subclass, or a Toolkit (returned as it is). settings - are the new toolkit's own (LambdaToolkit's analyzer=, clients=, prices=).""" + """A Toolkit from its name in TOOLKITS ('lambda', 's3'), a Toolkit subclass, or a Toolkit (returned as it is). + settings are the new toolkit's own (LambdaToolkit's analyzer=, clients=, prices=; S3Toolkit's analyzer=).""" if isinstance(spec, Toolkit): return spec if isinstance(spec, type) and issubclass(spec, Toolkit): @@ -32,7 +35,7 @@ def make_toolkit(spec: str | Toolkit | type[Toolkit], **settings: Any) -> Toolki hint = f" Did you mean {close[0]!r}?" if close else "" raise ValueError(f"There's no {spec!r} toolkit.{hint} The agent has: {', '.join(sorted(TOOLKITS))}") return TOOLKITS[name](**settings) - raise TypeError(f"toolkits= takes names like 'lambda', Toolkit subclasses or Toolkit objects, not {spec!r}") + raise TypeError(f"toolkits= takes names like 'lambda' or 's3', Toolkit subclasses or Toolkit objects, not {spec!r}") def default_toolkits() -> list[Toolkit]: @@ -58,5 +61,5 @@ def make_toolkits(specs: Iterable[str | Toolkit | type[Toolkit]] | None, return made -__all__ = ["DEFAULT_TOOLKITS", "TOOLKITS", "LambdaToolkit", "Toolkit", "default_toolkits", "make_toolkit", +__all__ = ["DEFAULT_TOOLKITS", "TOOLKITS", "LambdaToolkit", "S3Toolkit", "Toolkit", "default_toolkits", "make_toolkit", "make_toolkits"] diff --git a/src/aws_analyzer/agent/toolkits/base.py b/src/aws_analyzer/agent/toolkits/base.py index 2d3667c..ad720d1 100644 --- a/src/aws_analyzer/agent/toolkits/base.py +++ b/src/aws_analyzer/agent/toolkits/base.py @@ -11,6 +11,10 @@ one line) and `_next` ((View call, what it shows) pairs), which the agent takes out instructions(budget) its section of the system prompt: how to use its tools well not_found(exc, arguments) the result for a resource that isn't there (close names), or None + request_cost(counts) what its AWS requests cost (the guard's counts by operation), when they're billed + +A tool that joins two services reads the other one through that service's toolkit: peer('lambda') is the agent's +Lambda toolkit, or None when it hasn't got one (then the toolkit leaves the tool out). A tool raises what it likes: the agent's runtime (runtime.py) turns every error into the tool's result, and checks the question's budget before the tool runs. A tool that reads logs over a window checks the budget's log_days itself @@ -43,6 +47,8 @@ class Toolkit: reads: dict[str, tuple[str, ...]] = {} # tool name -> the AWS reads it makes (tools() and README list them) service: str = "" # the IAM prefix of most of its operations, for permission_for(): 'lambda' services: dict[str, str] = {} # operation -> its IAM prefix where that's another: {'FilterLogEvents': 'logs'} + permissions: dict[str, str] = {} # operation -> its IAM permission where it isn't :: + # {'ListObjectsV2': 's3:ListBucket'} view: str = "" # the View whose commands its tools suggest next: 'LambdaView' client_factory: str | None = None # the analyzer's method that makes clients, which the guard hooks too @@ -79,6 +85,12 @@ def not_found(self, exc: ClientError, arguments: dict[str, Any]) -> dict[str, An says so, and the closest names. None when the error is something else.""" return None + def request_cost(self, counts: dict[str, int]) -> float: + """USD for the AWS requests in `counts` ('s3:ListObjectsV2' -> 12, every service's, as the guard counted them) + that this toolkit's service bills per request. 0 when its reads are free (CloudWatch metrics are counted + apart, with _count_metrics).""" + return 0.0 + # ------------------------------------------------------------------ what it gets @property @@ -108,9 +120,18 @@ def regions(self, which: Any = None) -> list[str]: regions = getattr(self.analyzer, "regions", None) return regions(which) if callable(regions) else [self.region] + def peer(self, name: str) -> Toolkit | None: + """Another of the agent's toolkits, by its name ('lambda'), or None when the agent hasn't got it. A toolkit + offers the tools that join two services (S3's s3_triggers, which reads Lambda too) only when the agent has + both, and reads the other service through that toolkit's analyzer.""" + toolkits = getattr(self.agent, "toolkits", None) or [] + return next((t for t in toolkits if t.name == name and t is not self), None) + def permission_for(self, exc: ClientError) -> str: """The IAM permission a refused call needed: 'lambda:GetFunction' from the operation that failed.""" op = getattr(exc, "operation_name", "") or "" + if op in self.permissions: + return self.permissions[op] service = self.services.get(op, self.service) return f"{service}:{op}" if op and service else "" @@ -130,10 +151,14 @@ def _count_metrics(self, count: int, price: float) -> None: def _view_call(self, command: str, *args: Any, region: str | None = None, **kwargs: Any) -> str: """A call of the toolkit's View, as it runs in a new cell: "LambdaView().errors('etl', since='24h')". The View - reads the notebook's default region, so region= is added when the resource is anywhere else.""" + reads the notebook's default region, so region= is added when the resource is anywhere else. Arguments that + are None are left out: the command's own default applies.""" if region and region != self._default_region(): kwargs = {**kwargs, "region": region} - return f"{self.view}()." + _call(command, *args, **kwargs) + args = list(args) + while args and args[-1] is None: + args.pop() + return f"{self.view}()." + _call(command, *args, **{k: v for k, v in kwargs.items() if v is not None}) @staticmethod def _default_region() -> str | None: diff --git a/src/aws_analyzer/agent/toolkits/s3_tools.py b/src/aws_analyzer/agent/toolkits/s3_tools.py new file mode 100644 index 0000000..ea5a0b3 --- /dev/null +++ b/src/aws_analyzer/agent/toolkits/s3_tools.py @@ -0,0 +1,980 @@ +"""The S3 toolkit: tools over S3Analyzer for what a bucket holds and how it's set up, and the one that joins S3 and +Lambda. Which buckets there are (find_buckets), a bucket's settings, size, cost and event notifications +(bucket_details), a folder one level down (list_folder), the files under a location that match a filter +(find_files), what a location holds by type, storage class and folder (folder_summary), one file's headers +(file_info), and, when the agent has the Lambda toolkit too, s3_triggers: which bucket events invoke which functions, +and whether the files written in a window reached them. No tool reads what's inside a file. + +The summarize_* functions, count_arrivals() and trigger_findings() are pure: they run on any BucketConfig, +BucketMetrics, Listing, PrefixSummary, list of ObjectInfo or TriggerLink.""" + +from __future__ import annotations + +import difflib +import fnmatch +import heapq +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +from datetime import datetime +from typing import TYPE_CHECKING, Any, Callable, Iterable + +from botocore.exceptions import BotoCoreError, ClientError + +from ..._kit.errors import _error_code +from ..._kit.fmt import _plural, _utcnow, human_money, human_size, parse_time +from ..._kit.text import _clip +from ..budget import Budget +from ..results import _iso, _window, _with_permissions, hide_secrets +from .base import Toolkit + +if TYPE_CHECKING: # for the type hints only: the S3 analyzer loads when the first tool runs + from ...lambda_functions import Function, FunctionMetrics + from ...s3 import ( + BucketConfig, BucketInfo, BucketMetrics, EventNotification, Listing, ObjectInfo, PrefixSummary, S3Analyzer, + ) + +# What each tool reads (IAM permissions), for tools() and README. s3_triggers reads Lambda through the Lambda toolkit. +S3_TOOL_READS: dict[str, tuple[str, ...]] = { + "find_buckets": ("s3:ListAllMyBuckets", "s3:GetBucketLocation", "cloudwatch:ListMetrics", + "cloudwatch:GetMetricData"), + "bucket_details": ("s3:GetBucketLocation", "s3:GetBucketVersioning", "s3:GetEncryptionConfiguration", + "s3:GetBucketPublicAccessBlock", "s3:GetBucketPolicyStatus", "s3:GetBucketPolicy", + "s3:GetBucketOwnershipControls", "s3:GetBucketObjectLockConfiguration", + "s3:GetLifecycleConfiguration", "s3:GetReplicationConfiguration", "s3:GetBucketLogging", + "s3:GetBucketTagging", "s3:GetInventoryConfiguration", "s3:GetBucketNotification", + "s3:GetAccountPublicAccessBlock", "cloudwatch:ListMetrics", "cloudwatch:GetMetricData"), + "list_folder": ("s3:ListBucket",), + "find_files": ("s3:ListBucket",), + "folder_summary": ("s3:ListBucket",), + "file_info": ("s3:GetObject (HeadObject: its headers, never its content)", "s3:GetObjectTagging"), + "s3_triggers": ("s3:GetBucketNotification", "s3:ListBucket", "s3:ListAllMyBuckets", "s3:GetBucketLocation", + "lambda:GetFunction", "lambda:GetPolicy", "cloudwatch:GetMetricData"), +} +# USD per 1,000 requests (S3's us-east-1 list prices, 2026-10): LIST requests, and GET, HEAD and the rest. +S3_REQUEST_PRICES = {"list": 0.005, "get": 0.0004} +_METRIC_PRICE = 0.01 # USD per 1,000 metrics read with CloudWatch GetMetricData +_SIZED_BUCKETS = 200 # find_buckets(sizes=True) reads CloudWatch for at most this many buckets +_SEARCHED_BUCKETS = 50 # s3_triggers looks through at most this many buckets for notifications that call a function +_CONFIG_PERMISSIONS = { # BucketConfig.errors section (and this toolkit's own parts) -> the permission it needs + "versioning": "s3:GetBucketVersioning", "encryption": "s3:GetEncryptionConfiguration", + "public_access_block": "s3:GetBucketPublicAccessBlock", "policy": "s3:GetBucketPolicyStatus", + "policy_document": "s3:GetBucketPolicy", "ownership": "s3:GetBucketOwnershipControls", + "object_lock": "s3:GetBucketObjectLockConfiguration", "lifecycle": "s3:GetLifecycleConfiguration", + "replication": "s3:GetReplicationConfiguration", "logging": "s3:GetBucketLogging", "tags": "s3:GetBucketTagging", + "inventory": "s3:GetInventoryConfiguration", "notifications": "s3:GetBucketNotification", + "metrics": "cloudwatch:GetMetricData", "list": "s3:ListBucket", "buckets": "s3:ListAllMyBuckets", + "function": "lambda:GetFunction", "policy_of_function": "lambda:GetPolicy", +} + + +def _s3_module() -> Any: + """aws_analyzer.s3, which the tools are built on. Loaded when a tool first needs it, so importing the agent loads + no analyzer.""" + from ... import s3 + + return s3 + + +def _code(exc: BaseException) -> str: + return _error_code(exc) if isinstance(exc, ClientError) else type(exc).__name__ + + +def _between(since: str, until: str, now: datetime | None = None) -> tuple[datetime | None, datetime]: + """(start, end) of a tool's since= and until=: ISO UTC times ('2026-10-15T18:00Z') or relative ones ('24h'). The + start is None when since is empty, and the end is now when until is.""" + now = now or _utcnow() + since, until = str(since or "").strip(), str(until or "").strip() + try: + start = parse_time(since, now=now) if since else None + end = parse_time(until, now=now) if until else now + except ValueError: + raise ValueError(f"since= and until= take ISO UTC times ('2026-10-15T18:00Z') or relative ones ('24h', " + f"'7d'); got since={since!r}, until={until!r}") from None + if start is not None and end is not None and start >= end: + raise ValueError(f"since= ({since!r}) must be before until= ({until or 'now'!r})") + return start, end or now + + +# ============================================================================= +# What the tools return: S3Analyzer's results as compact JSON (pure) +# ============================================================================= + + +def _shown(values: dict[str, Any]) -> dict[str, str]: + """Tags or metadata as the model reads them: each value clipped, and hidden when it looks like a secret, by + itself or by its name ({'api_key': 'sk-...'} -> {'api_key': '[hidden]'}).""" + out = {} + for key, value in values.items(): + text = hide_secrets(f"{key}={_clip(str(value), 200)}") + out[key] = text[len(key) + 1:] if text.startswith(f"{key}=") else hide_secrets(_clip(str(value), 200)) + return out + + +def file_row(obj: ObjectInfo) -> dict[str, Any]: + """One file as the agent sees it: its key, size, when it was last written, its storage class, and whether it + was uploaded in parts (which S3 events it sent depends on that).""" + row: dict[str, Any] = {"key": obj.key, "size": human_size(obj.size), "modified": obj.last_modified, + "storage_class": obj.storage_class} + if obj.is_multipart: + row["multipart"] = True + return row + + +def summarize_buckets(buckets: list[BucketInfo], metrics: dict[str, BucketMetrics] | None = None, + errors: dict[str, str] | None = None, *, limit: int = 100) -> dict[str, Any]: + """find_buckets()'s answer: every bucket (by name, or biggest first when `metrics` has their sizes), with its + region and when it was made, and its object count and size from CloudWatch when they were read.""" + rows = [] + for b in buckets: + row: dict[str, Any] = {"name": b.name, "region": b.region, "created": b.created} + m = (metrics or {}).get(b.name) + if metrics is not None and m is not None: + row["objects"] = m.object_count + row["size"] = human_size(m.total_size) if m.size_by_storage_type else None + row["_bytes"] = m.total_size + rows.append(row) + rows.sort(key=lambda r: (-(r.get("_bytes") or 0), r["name"]) if metrics is not None else r["name"]) + for row in rows: + row.pop("_bytes", None) + out: dict[str, Any] = {"count": len(buckets), "regions": sorted({b.region for b in buckets if b.region}), + "buckets": rows[:limit]} + if len(rows) > limit: + out["more"] = f"{len(rows) - limit:,} more not listed: narrow with match='prefix-*'" + if metrics is not None: + out["sizes_note"] = ("From CloudWatch's daily storage metrics (up to a day old). A bucket without numbers is " + "new, empty, or has no metrics yet.") + if errors: + out["errors"] = _with_permissions(errors, _CONFIG_PERMISSIONS) + return out + + +def summarize_bucket(cfg: BucketConfig, metrics: BucketMetrics | None, notifications: list[EventNotification] | None, + *, account_block: dict[str, bool] | None = None, prices: dict[str, float] | None = None, + errors: dict[str, str] | None = None) -> dict[str, Any]: + """bucket_details()'s answer: a bucket's settings in plain words (public access, encryption, versioning, + lifecycle rules, replication, logging, tags), its size and estimated monthly storage cost from CloudWatch, the + events it sends where (Lambda functions, queues, topics, EventBridge), and the findings about it.""" + s3 = _s3_module() + errors = {**cfg.errors, **(errors or {})} + out: dict[str, Any] = { + "bucket": cfg.name, "region": cfg.region, + "public_access": s3._exposure(cfg, account_block), + "block_public_access": s3._block_label(cfg.public_access_block), + "encryption": s3._encryption_label(cfg), "kms_key": cfg.kms_key, + "versioning": cfg.versioning, "object_ownership": cfg.object_ownership, "object_lock": cfg.object_lock, + "lifecycle_rules": [{"id": rule.get("ID"), "status": rule.get("Status"), "applies_to": s3._rule_scope(rule), + "does": s3._rule_actions(rule).replace("\n", "; ")} for rule in cfg.lifecycle_rules], + "replication_rules": len(cfg.replication_rules), "access_logs_to": cfg.logging_target, + "tags": _shown(cfg.tags), + } + if metrics is not None and (metrics.size_by_storage_type or metrics.object_count is not None): + costs = s3.cloudwatch_cost(metrics.size_by_storage_type, prices) + known = [c for c in costs.values() if c is not None] + out["size"] = {"objects": metrics.object_count, "total": human_size(metrics.total_size), + "by_storage_type": {k: human_size(v) for k, v in metrics.size_by_storage_type.items()}, + "as_of": metrics.as_of} + out["monthly_storage_cost_usd"] = { + "total": round(sum(known), 2) if known else None, + "basis": "estimate: CloudWatch's daily size at us-east-1 list prices; storage only (no requests or " + "data transfer)"} + elif metrics is not None: + out["size"] = {"note": "CloudWatch has no storage numbers for it yet (new or empty buckets have none)."} + if notifications is not None: + out["event_notifications"] = [{"sends": n.describe(), "to": n.target, "name": n.name if n.arn else None, + "events": n.events, "prefix": n.prefix, "suffix": n.suffix} + for n in notifications] + found = s3.bucket_findings(cfg, account_block) + if cfg.policy: + found += s3.policy_findings(s3.explain_policy(cfg.policy)) + out["findings"] = [{"level": level, "message": text} for level, text in found + if not text.startswith("Couldn't read")] # what couldn't be read is in errors + if errors: + out["errors"] = _with_permissions(errors, _CONFIG_PERMISSIONS) + return out + + +def summarize_listing(listing: Listing, *, folders: int = 100, files: int = 50) -> dict[str, Any]: + """list_folder()'s answer: the folders and files directly under a location (one level), the newest files first.""" + newest = sorted(listing.objects, key=lambda o: o.last_modified, reverse=True) + out: dict[str, Any] = { + "location": listing.uri, "folders_found": len(listing.folders), "files_found": len(listing.objects), + "folders": listing.folders[:folders], + "files_newest_first": [file_row(o) for o in newest[:files]], + "cut_short": listing.truncated, + } + if len(listing.folders) > folders: + out["more_folders"] = len(listing.folders) - folders + if len(newest) > files: + out["more_files"] = len(newest) - files + if listing.truncated: + out["note"] = "The folder holds more than was listed: find_files or folder_summary read all of it." + return out + + +def summarize_found(location: str, found: list[ObjectInfo], *, scanned: int, cut_short: bool, + start: datetime | None = None, end: datetime | None = None, + filters: dict[str, Any] | None = None, limit: int = 30) -> dict[str, Any]: + """find_files()'s answer: how many files under a location match, their total size, when the oldest and newest + were written, and the newest of them; `scanned` keys were read, and `cut_short` when the listing stopped early.""" + newest = heapq.nlargest(limit, found, key=lambda o: o.last_modified) + out: dict[str, Any] = {"location": location, "filters": {k: v for k, v in (filters or {}).items() if v}} + if start is not None: + out["window"] = _window(start, end or _utcnow()) + out.update({ + "matched": len(found), "total_size": human_size(sum(o.size for o in found)), + "oldest_written": min((o.last_modified for o in found), default=None), + "newest_written": max((o.last_modified for o in found), default=None), + "newest_first": [file_row(o) for o in newest], + "scanned": scanned, "cut_short": cut_short, + }) + if len(found) > limit: + out["more"] = len(found) - limit + if cut_short: + out["note"] = (f"Stopped after {scanned:,} keys (the s3_keys budget), in key order: the counts cover those " + "only. A narrower location reads all of it.") + return out + + +def summarize_prefix(summary: PrefixSummary, prices: dict[str, float] | None = None, *, top: int = 8) -> dict[str, Any]: + """folder_summary()'s answer: what a location holds (files, total size, by file type, storage class and + folder), the oldest and newest file, the largest ones, the estimated monthly storage cost and the findings.""" + s3 = _s3_module() + + def stats(by: dict[str, Any], n: int = top) -> list[dict[str, Any]]: + ordered = sorted(by.items(), key=lambda kv: -kv[1].size) + return [{"name": name or "(directly here)", "files": st.count, "size": human_size(st.size)} + for name, st in ordered[:n]] + + out: dict[str, Any] = { + "location": summary.uri, "files": summary.object_count, "total_size": human_size(summary.total_size), + "by_type": stats(summary.by_extension), "by_storage_class": stats(summary.by_storage_class), + "by_folder": stats(summary.by_folder, 10), + "oldest": file_row(summary.oldest) if summary.oldest else None, + "newest": file_row(summary.newest) if summary.newest else None, + "largest": [file_row(o) for o in summary.largest[:5]], + "empty_files": summary.empty_count, + "monthly_storage_cost_usd": round(summary.monthly_cost, 2), + "cost_basis": "estimate: us-east-1 list prices, storage only", + "findings": [{"level": level, "message": text} for level, text in s3.summary_findings(summary, prices)], + "scanned": summary.object_count + summary.folder_markers, "cut_short": summary.truncated, + } + if len(summary.by_folder) > 10: + out["more_folders"] = len(summary.by_folder) - 10 + return out + + +def summarize_file(info: dict[str, Any]) -> dict[str, Any]: + """file_info()'s answer: one file's headers (size, when it was written, type, storage class, encryption, version, + restore and lock status, its metadata and tags, with anything that looks like a secret hidden) and what they + mean for reading it. Never its content.""" + s3 = _s3_module() + out: dict[str, Any] = {k: v for k, v in info.items() if k not in ("metadata", "tags", "size", "etag")} + out["size"] = human_size(info.get("size")) + out["metadata"] = _shown(info.get("metadata") or {}) + tags = info.get("tags") + out["tags"] = _shown(tags) if tags is not None else "not read: needs s3:GetObjectTagging" + notes = [] + archived = info.get("storage_class") in s3.ARCHIVE_CLASSES or info.get("archive_status") + if archived and 'ongoing-request="false"' not in str(info.get("restore")): + tier = info.get("archive_status") or info.get("storage_class") + notes.append(f"It's archived ({tier}): nothing can read it (a Lambda function included) until it's " + "restored. S3View().head() shows the restore call.") + if info.get("encryption") in ("aws:kms", "aws:kms:dsse"): + notes.append(f"It's encrypted with KMS key {info.get('kms_key') or '(the bucket default)'}: whatever reads it " + "(a Lambda function's role included) also needs kms:Decrypt on that key.") + if notes: + out["notes"] = notes + return out + + +# ---- S3 and Lambda: the event notifications that invoke functions, checked from both ends + + +@dataclass +class Arrivals: + """What a listing of a notification's prefix says about the files written in a window (count_arrivals).""" + + scanned: int = 0 # keys read + matched: int = 0 # files written in the window that its prefix and suffix cover + sent: int = 0 # of those, the ones uploaded in a way its events fire on: they invoked the function + multipart_missed: int = 0 # matched, but uploaded in parts, and it doesn't fire on CompleteMultipartUpload + whole_missed: int = 0 # matched, but uploaded whole, and it fires only on multipart uploads + missed_keys: list[str] = field(default_factory=list) # a few of the files its events missed + case_missed: list[str] = field(default_factory=list) # files in the window whose ending matches only ignoring case + newest: list[ObjectInfo] = field(default_factory=list) # the newest files that invoked it (up to 5) + last_match: ObjectInfo | None = None # the newest file its filter covers, in the window or before + cut_short: bool = False # the listing stopped at its limit + + +@dataclass +class TriggerLink: + """One S3 event notification that invokes a Lambda function, checked from both ends (s3_triggers).""" + + bucket: str + notification: EventNotification + exists: bool | None = None # the function is there; None when it couldn't be checked + allowed: bool | None = None # its resource policy lets this bucket invoke it; None when it couldn't be read + arrivals: Arrivals | None = None # None when it doesn't fire on new files, or the listing failed + metrics: FunctionMetrics | None = None # CloudWatch's counts for the function over the window + account: str | None = None # the function's account (for the add-permission command) + + @property + def function(self) -> str: + return self.notification.name + + @property + def region(self) -> str | None: + return self.notification.region + + +def count_arrivals(objects: Iterable[ObjectInfo], notification: EventNotification, start: datetime, end: datetime, + *, limit: int | None = None, show: int = 5) -> Arrivals: + """What a listing of the notification's prefix says about the files written from `start` to `end`: how many its + prefix and suffix cover, and how many of those it fired on, by how each was uploaded (an ETag with a part count + means multipart, which sends s3:ObjectCreated:CompleteMultipartUpload; anything else sent a Put, Post or Copy + event), and the files its suffix misses only by case ('.CSV' for '.csv': S3 compares case-sensitively). `limit` + caps the keys read (cut_short when there were more).""" + out = Arrivals() + suffix, prefix = notification.suffix, notification.prefix + sent: list[ObjectInfo] = [] + for obj in objects: + if limit is not None and out.scanned >= limit: + out.cut_short = True + break + out.scanned += 1 + if obj.is_folder_marker or not obj.key.startswith(prefix): + continue + written = start <= obj.last_modified < end + if obj.key.endswith(suffix): + if out.last_match is None or obj.last_modified > out.last_match.last_modified: + out.last_match = obj + if not written: + continue + out.matched += 1 + if notification.on_created(obj.is_multipart): + out.sent += 1 + sent.append(obj) + continue + if obj.is_multipart: + out.multipart_missed += 1 + else: + out.whole_missed += 1 + if len(out.missed_keys) < 3: + out.missed_keys.append(obj.key) + elif written and suffix and obj.key.lower().endswith(suffix.lower()): + out.case_missed.append(obj.key) + out.newest = heapq.nlargest(show, sent, key=lambda o: o.last_modified) + return out + + +def _where(n: EventNotification) -> str: + """"under 'incoming/' ending in '.csv'", or "anywhere in the bucket".""" + parts = [f"under '{n.prefix}'" if n.prefix else "", f"ending in '{n.suffix}'" if n.suffix else ""] + return " ".join(filter(None, parts)) or "anywhere in the bucket" + + +def _events(n: EventNotification) -> str: + return ", ".join(n.events) or "no events" + + +def trigger_findings(link: TriggerLink) -> list[tuple[str, str]]: + """What's wrong with one S3 -> Lambda link, or what explains it, each with what to do: a notification that + points at a function that isn't there, a function that doesn't let the bucket invoke it, files the filter or the + event types miss, no file arriving at all, files that arrived without the function running, and runs that + failed. (level, message) pairs.""" + n, fn, bucket = link.notification, link.function, link.bucket + where = _where(n) + found: list[tuple[str, str]] = [] + if link.exists is False: + found.append(("warn", f"Bucket {bucket} sends {_events(n)} for files {where} to Lambda function {fn}, which " + f"isn't there in {link.region}: those events invoke nothing. Point the notification at " + "the function that should run, or remove it (aws s3api " + f"get-bucket-notification-configuration --bucket {bucket} shows it).")) + return found + if link.allowed is False: + account = f" --source-account {link.account}" if link.account else "" + found.append(("warn", f"{fn}'s resource policy doesn't let bucket {bucket} invoke it: S3 can't deliver the " + f"events for files {where}, so none of them run it. Allow it: aws lambda add-permission " + f"--function-name {fn} --statement-id s3-{bucket} --action lambda:InvokeFunction " + f"--principal s3.amazonaws.com --source-arn arn:aws:s3:::{bucket}{account}" + + (f" --region {link.region}" if link.region else ""))) + a, m = link.arrivals, link.metrics + if a is None and not n.on_created(): + found.append(("info", f"Bucket {bucket} invokes {fn} on {_events(n)}, not on new files, so the listing can't " + f"show what it sent: CloudWatch's count of {fn}'s runs is the record.")) + if a is not None: + if a.multipart_missed or a.whole_missed: + missed = a.multipart_missed + a.whole_missed + was, they = ("was", "it") if missed == 1 else ("were", "they") + how = ("uploaded in parts (multipart), which sends s3:ObjectCreated:CompleteMultipartUpload" if + a.multipart_missed else "uploaded whole (a PUT), which sends s3:ObjectCreated:Put") + found.append(("warn", f"{_plural(missed, 'file')} {where} written in the window {was} {how}, but the " + f"notification fires only on {_events(n)}: {they} never invoked {fn} (e.g. " + f"{a.missed_keys[0]}). Make it fire on s3:ObjectCreated:* instead.")) + if a.case_missed: + endings = sorted({key[-len(n.suffix):] for key in a.case_missed}) + end, they = ("ends", "it") if len(a.case_missed) == 1 else ("end", "they") + found.append(("warn", f"{_plural(len(a.case_missed), 'file')} under '{n.prefix}' written in the window " + f"{end} in {', '.join(repr(e) for e in endings)}, which the suffix filter " + f"'{n.suffix}' doesn't match (S3 compares case-sensitively): {they} never invoked " + f"{fn} (e.g. {a.case_missed[0]}). Have the uploader write '{n.suffix}', or add a " + "notification for the other ending.")) + if not a.matched and not a.case_missed: + last = (f" The newest file it covers is {a.last_match.key}, written {_iso(a.last_match.last_modified)}." + if a.last_match else " No file there matches its filter at all.") + found.append(("info", f"No file {where} in bucket {bucket} was written in the window" + + (" (in the keys read)" if a.cut_short else "") + + f", so S3 had nothing to send {fn}: if it didn't run, that's why.{last}")) + if m is not None and a.sent: + calls, throttles = int(m.invocations), int(m.throttles) + throttled = f" ({_plural(throttles, 'call')} throttled)" if throttles else "" + if not calls and link.allowed is not False: + found.append(("warn", f"{_plural(a.sent, 'file')} {where} arrived in the window and should have " + f"invoked {fn}, but CloudWatch counted no runs of it{throttled}: S3's events " + f"didn't reach it. function_details('{fn}') shows whether S3 may invoke it " + "and its reserved concurrency (0 stops every run).")) + elif 0 < calls < a.sent: + found.append(("info", f"{_plural(a.sent, 'file')} arrived that should have invoked {fn}, but it ran " + f"{_plural(calls, 'time')} in the window{throttled}: some events may not have " + "reached it yet, since Lambda queues S3's events and retries them for up to 6 " + "hours.")) + if a.cut_short: + found.append(("info", f"The listing of bucket {bucket} {where} stopped at {a.scanned:,} keys (the budget's " + "s3_keys), in key order: the file counts cover those keys only. A notification " + "with a narrower prefix, or AgentView(budget={'s3_keys': " + f"{a.scanned * 5}}}), reads more.")) + if m is not None and m.errors: + found.append(("warn", f"{fn} failed {_plural(int(m.errors), 'time')} in {_plural(int(m.invocations), 'run')} " + f"in the window: function_errors('{fn}') shows why. S3 invokes it asynchronously, so a " + "failed event is retried twice, then dropped unless the function has an on-failure " + "destination.")) + return found + + +def summarize_triggers(links: list[TriggerLink], start: datetime, end: datetime, *, bucket: str = "", + function: str = "", others: list[EventNotification] | None = None, + notes: list[tuple[str, str]] | None = None, errors: dict[str, str] | None = None, + searched: list[str] | None = None) -> dict[str, Any]: + """s3_triggers()'s answer: each S3 -> Lambda link (the events and keys it fires on, whether the function is there + and lets the bucket invoke it, the files written in the window that it covers and fired on, and the function's + runs and failures in that window), the bucket's other notification targets, and every finding.""" + found: list[tuple[str, str]] = list(notes or []) + rows = [] + for link in links: + n, a, m = link.notification, link.arrivals, link.metrics + row: dict[str, Any] = { + "bucket": link.bucket, "function": link.function, "function_region": link.region, + "notification_id": n.id or None, "fires_on": n.events, "prefix": n.prefix, "suffix": n.suffix, + "function_exists": link.exists, "s3_may_invoke": link.allowed, + } + if a is not None: + row["files_in_window"] = { + "covered_by_filter": a.matched, "invoked_it": a.sent, + "missed_by_upload_kind": a.multipart_missed + a.whole_missed, "missed_by_case": len(a.case_missed), + "newest_that_invoked_it": [file_row(o) for o in a.newest], + "keys_read": a.scanned, "cut_short": a.cut_short, + } + row["newest_file_it_covers"] = file_row(a.last_match) if a.last_match else None + if m is not None: + row["function_in_window"] = {"runs": int(m.invocations), "failed": int(m.errors), + "throttled": int(m.throttles)} + rows.append(row) + found += trigger_findings(link) + if not links: + if bucket and function: + found.append(("info", f"No event notification of bucket {bucket} invokes {function}.")) + elif bucket: + found.append(("info", f"Bucket {bucket}'s event notifications invoke no Lambda function." + + (" EventBridge gets its events, so an EventBridge rule may invoke one: " + "function_details lists a function's EventBridge rules." if any( + o.target == "EventBridge" for o in others or []) else ""))) + elif function: + looked = (f" (checked {_plural(len(searched), 'bucket')}: {', '.join(searched[:10])}" + + (", ..." if len(searched) > 10 else "") + ")") if searched else "" + found.append(("info", f"No bucket's event notification invokes {function}{looked}.")) + out: dict[str, Any] = { + "window": _window(start, end), "bucket": bucket or None, "function": function or None, + "links": rows, + "other_targets": [o.describe() for o in others or []], + "findings": [{"level": level, "message": text} for level, text in found], + "scanned": sum(link.arrivals.scanned for link in links if link.arrivals), + "cut_short": any(link.arrivals.cut_short for link in links if link.arrivals), + "note": "File times are when each object was last written: an overwritten file shows its newest write, and a " + "deleted one doesn't show. Runs are CloudWatch's count for the whole function, whatever invoked it.", + } + if searched: + out["buckets_checked"] = searched + if errors: + out["errors"] = _with_permissions(errors, _CONFIG_PERMISSIONS) + return out + + +def _points_at(n: EventNotification, fn: Function) -> bool: + """Whether a notification invokes this function (any version or alias of it).""" + parts, own = (n.arn or "").split(":"), fn.arn.split(":") + return (n.target == "Lambda function" and len(parts) > 6 and parts[6] == fn.name and parts[3] == fn.region + and (len(own) < 5 or parts[4] == own[4])) + + +def _s3_may_invoke(triggers: list[Any], bucket: str) -> bool: + """Whether a function's resource policy (as Lambda's parse_policy reads it) lets S3 invoke it for this bucket: + a statement for S3 limited to this bucket's ARN (or a pattern it matches), to an account, or not limited.""" + arn = f"arn:aws:s3:::{bucket}" + for t in triggers: + if t.kind == "S3 bucket" or t.principal == "s3.amazonaws.com": + if t.arn is None or fnmatch.fnmatchcase(arn, t.arn): + return True + return False + + +# ============================================================================= +# S3Toolkit +# ============================================================================= + + +class S3Toolkit(Toolkit): + """The agent's tools for Amazon S3, reading through S3Analyzer: `analyzer` is one to use (else one is made on the + agent's session). With the Lambda toolkit beside it, it also has s3_triggers, which reads Lambda through that + toolkit's analyzer.""" + + name = "s3" + title = "S3" + privacy = "File contents are never read: the S3 tools see names, sizes, dates, storage classes and settings." + reads = S3_TOOL_READS + service = "s3" + services = {"GetMetricData": "cloudwatch", "ListMetrics": "cloudwatch", "GetFunction": "lambda", + "GetPolicy": "lambda", "ListFunctions": "lambda"} + permissions = { + "ListObjectsV2": "s3:ListBucket", "ListBuckets": "s3:ListAllMyBuckets", "HeadObject": "s3:GetObject", + "HeadBucket": "s3:ListBucket", "GetBucketNotificationConfiguration": "s3:GetBucketNotification", + "GetBucketEncryption": "s3:GetEncryptionConfiguration", + "GetBucketLifecycleConfiguration": "s3:GetLifecycleConfiguration", + "GetBucketReplication": "s3:GetReplicationConfiguration", + "GetObjectLockConfiguration": "s3:GetBucketObjectLockConfiguration", + "ListBucketInventoryConfigurations": "s3:GetInventoryConfiguration", + } + view = "S3View" + + @property + def covers(self) -> str: + links = (", and which S3 events invoke which Lambda functions and whether the files that arrived ran them" + if self.peer("lambda") else "") + return ("Amazon S3: which buckets exist and how they're set up (public access, encryption, versioning, " + "lifecycle rules, event notifications), their size and estimated storage cost, what's in a folder and " + f"which files were written when, and one file's headers (never what's inside a file){links}.") + + def make_analyzer(self, session: Any) -> S3Analyzer: + return _s3_module().S3Analyzer(session) + + @property + def analyzer(self) -> S3Analyzer: + """The S3Analyzer the tools read through, made on first use and hooked to the read-only guard.""" + return super().analyzer + + def instructions(self, budget: Budget) -> str: + text = f"""\ +Amazon S3 +- Locations are 's3://bucket/prefix' (or 'bucket/prefix'). Keys and prefixes are case-sensitive. +- list_folder reads one level and is fast. find_files and folder_summary read every key under a location, up to + {budget.s3_keys:,} keys: give them the narrowest prefix you can (a date's folder rather than the whole bucket). +- The S3 tools never read what's inside a file: names, sizes, dates, storage classes and settings only. Say so when + asked about a file's contents. +- A file's time is when it was last written: an overwritten file shows only its newest write, and a deleted file + doesn't show at all.""" + if self.peer("lambda"): + text += """ + +S3 and Lambda together +- For a function that S3 events invoke ("why didn't orders-etl run?", "were last night's files processed?", "which + Lambda processes uploads to acme-raw?"), call s3_triggers first: it joins the bucket's event notifications, the + files written in the window and the function's runs and failures in that window. Then function_errors or + function_logs (search= a file's key) for the runs that failed. +- function_details lists the buckets a function's resource policy lets invoke it; s3_triggers checks what those + buckets actually send it.""" + return text + + def request_cost(self, counts: dict[str, int]) -> float: + """USD for the S3 requests in `counts` (the guard's 's3:ListObjectsV2' -> n), at S3_REQUEST_PRICES.""" + lists = sum(n for op, n in counts.items() if op.startswith("s3:List")) + others = sum(n for op, n in counts.items() if op.startswith("s3:") and not op.startswith("s3:List")) + return (lists * S3_REQUEST_PRICES["list"] + others * S3_REQUEST_PRICES["get"]) / 1000 + + # ------------------------------------------------------------------ the tools + + def tools(self) -> list[Callable[..., dict[str, Any]]]: + """find_buckets, bucket_details, list_folder, find_files, folder_summary and file_info, and s3_triggers when + the agent has the Lambda toolkit too: plain functions (their type hints and docstrings tell the model how to + call them), each reading through S3Analyzer and returning a compact, JSON-ready summary.""" + + def find_buckets(match: str = "", sizes: bool = False) -> dict[str, Any]: + """Lists the S3 buckets in the account: name, region and when each was made. match: a name glob like + 'acme-*'. sizes=True also reads each bucket's object count and size from CloudWatch's daily storage + metrics and sorts the biggest first (two reads per bucket: slower when there are many).""" + core = self.analyzer + buckets = [b for b in core.list_buckets() if not match or fnmatch.fnmatchcase(b.name, match)] + metrics: dict[str, BucketMetrics] | None = None + errors: dict[str, str] = {} + if sizes: + metrics, errors = self._sizes(buckets[:_SIZED_BUCKETS]) + out = summarize_buckets(buckets, metrics, errors) + if sizes and len(buckets) > _SIZED_BUCKETS: + out["sizes_note"] += f" Sizes were read for the first {_SIZED_BUCKETS} only: narrow with match=." + out["_summary"] = f"{_plural(len(buckets), 'bucket')}" + ( + f" in {', '.join(out['regions'][:4])}" if out["regions"] else "") + out["_next"] = [(self._view_call("overview", match or None), + "every bucket: size, cost, encryption, public access and warnings")] + return out + + def bucket_details(bucket: str) -> dict[str, Any]: + """Everything about one bucket's setup: public access, encryption, versioning, object lock, lifecycle + rules in plain words, replication, access logging, tags, its event notifications (which Lambda + functions, queues and topics it sends which events to, for which keys), its object count, size and + estimated monthly storage cost from CloudWatch, and the findings about it with what to do.""" + s3 = _s3_module() + core = self.analyzer + name, _ = s3.parse_s3_uri(bucket) + core.bucket_region(name) # a bucket that isn't there fails here, with close names + cfg = core.bucket_config(name) + errors: dict[str, str] = {} + notifications = metrics = account = None + try: + notifications = core.notifications(name) + except (ClientError, BotoCoreError) as exc: + errors["notifications"] = _code(exc) + try: + metrics = core.bucket_metrics(name) + self._count_metrics(_metrics_read(metrics), _METRIC_PRICE) + except (ClientError, BotoCoreError) as exc: + errors["metrics"] = _code(exc) + try: + account = core.account_public_access_block() + except (ClientError, BotoCoreError, ValueError): + account = None # unknown: the findings say account-level settings may still apply + out = summarize_bucket(cfg, metrics, notifications, account_block=account, prices=core.prices, + errors=errors) + size = out.get("size", {}).get("total") + out["_summary"] = ", ".join(filter(None, [ + size and f"{size}", out["public_access"], f"encryption {out['encryption']}", + f"{_plural(len(notifications), 'event notification')}" if notifications is not None else "", + _plural(len(out["findings"]), "finding")])) + out["_next"] = [(self._view_call("bucket_info", name), "its settings, policy and size as a report")] + if cfg.policy: + out["_next"].append((self._view_call("policy", name), "its bucket policy in plain English")) + return out + + def list_folder(location: str) -> dict[str, Any]: + """What's directly in a folder (one level, like `aws s3 ls`): its subfolders, and its files newest first + with size, when each was written and storage class. Fast even on huge buckets. location: + 's3://bucket/prefix/' or 'bucket/prefix/'; a bucket name alone lists its top level.""" + listing = self.analyzer.ls(location, limit=1000) + out = summarize_listing(listing) + out["_summary"] = (f"{_plural(len(listing.folders), 'folder')}, {_plural(len(listing.objects), 'file')}" + + (" (more not listed)" if listing.truncated else "")) + out["_next"] = [(self._view_call("ls", listing.uri), "the same folder as a report")] + return out + + def find_files(location: str, match: str = "", since: str = "", until: str = "", min_size: str = "", + max_size: str = "", limit: int = 30) -> dict[str, Any]: + """The files under a location (every level below it) that match a filter: how many, their total size, + when the oldest and newest were written, and the newest `limit` of them. match: a glob on the file name, + or on the whole key when it has a '/' ('*.csv', 'orders_2026-10-1*'). since/until: only files last + written in that window, as ISO UTC times ('2026-10-15T18:00Z') or relative ('24h', '7d'). min_size / + max_size: '10MB', '1GB'. Reads the keys one listing page at a time, up to the budget's limit.""" + s3 = _s3_module() + core = self.analyzer + start, end = _between(since, until) + keep = s3.make_filter(pattern=match or None, min_size=min_size or None, max_size=max_size or None, + modified_after=start, modified_before=end if until else None) + cap = self.budget.s3_keys + scanned, found, more = 0, [], False + for obj in core.iter_objects(location, limit=cap + 1): # one past the cap says there's more + if scanned >= cap: + more = True + break + scanned += 1 + if keep(obj): + found.append(obj) + bucket, prefix = s3.parse_s3_uri(location) + out = summarize_found(s3.s3_uri(bucket, prefix), found, scanned=scanned, cut_short=more, start=start, + end=end, limit=max(1, min(int(limit or 30), 200)), + filters={"match": match, "min_size": min_size, "max_size": max_size}) + out["_summary"] = (f"{_plural(len(found), 'file')} ({out['total_size']})" + + (f" of {scanned:,} keys" if scanned != len(found) else "") + + (", cut short" if out["cut_short"] else "")) + out["_next"] = [(self._view_call("find", s3.s3_uri(bucket, prefix), pattern=match or None, + min_size=min_size or None, max_size=max_size or None, + modified_after=_iso(start), modified_before=_iso(end) if until else None), + "the same search as a report")] + return out + + def folder_summary(location: str) -> dict[str, Any]: + """What a location holds, every level below it: how many files and how much, by file type, storage + class and folder, the oldest and newest file, the largest ones, the estimated monthly storage cost and + findings (many small files, files in Glacier, files untouched for 90+ days...). Reads every key, up to + the budget's limit: use the narrowest location you can.""" + core = self.analyzer + summary = core.summarize(location, top_n=5, folder_depth=1, limit=self.budget.s3_keys) + out = summarize_prefix(summary, core.prices) + out["_summary"] = (f"{_plural(summary.object_count, 'file')}, {out['total_size']}, about " + f"{human_money(summary.monthly_cost)}/month" + (", cut short" if summary.truncated + else "")) + out["_next"] = [(self._view_call("summary", summary.uri), "the same as a dashboard"), + (self._view_call("tree", summary.uri), "its folders' sizes as a tree")] + return out + + def file_info(location: str) -> dict[str, Any]: + """One file's headers, never its content: size, when it was written, content type, storage class (a + file in Glacier must be restored before anything can read it), encryption (a KMS key means readers need + kms:Decrypt on it), version, restore and lock status, its metadata and tags. location: + 's3://bucket/key'.""" + core = self.analyzer + info = core.head(location) + out = summarize_file(info) + out["_summary"] = f"{out['size']}, {info.get('storage_class', 'STANDARD')}, written " + ( + _iso(info.get("last_modified")) or "?") + out["_next"] = [(self._view_call("head", info["uri"]), "every header as a report"), + (self._view_call("preview", info["uri"]), "what's in it, in your notebook only")] + return out + + tools = [find_buckets, bucket_details, list_folder, find_files, folder_summary, file_info] + if self.peer("lambda") is not None: + tools.append(self._triggers_tool()) + return tools + + def _triggers_tool(self) -> Callable[..., dict[str, Any]]: + def s3_triggers(bucket: str = "", function: str = "", since: str = "24h", until: str = "", + region: str = "") -> dict[str, Any]: + """How S3 and Lambda are wired: the bucket event notifications that invoke Lambda functions. Give a + bucket (the functions its events invoke), a function (the buckets whose events invoke it, from its + resource policy and those buckets' notifications) or both. For each link: the events and the key + prefix and suffix it fires on, whether the function exists and lets that bucket invoke it, how many files + the filter covers were written in the window and how many of them invoked it (and which it missed, by + upload kind or by the case of their ending), and how often the function ran, failed and was throttled in + that window (CloudWatch). Use it for "which Lambda processes files in X", "why didn't Y run after the + upload" or "were last night's files processed". since/until: ISO UTC times or relative ('24h'). region: + the function's region when it isn't the default.""" + return self._triggers(bucket, function, since, until, region) + + return s3_triggers + + # ------------------------------------------------------------------ what the tools share + + def _sizes(self, buckets: list[BucketInfo]) -> tuple[dict[str, BucketMetrics], dict[str, str]]: + """Each bucket's CloudWatch size and object count, read side by side; errors by 'bucket:metrics'.""" + core = self.analyzer + for region in {b.region for b in buckets if b.region}: + core._cloudwatch_in(region) # boto3 sessions aren't thread-safe: make the clients before the threads + + def size(bucket: BucketInfo) -> tuple[str, BucketMetrics | None, str | None]: + try: + return bucket.name, core.bucket_metrics(bucket.name), None + except (ClientError, BotoCoreError) as exc: + return bucket.name, None, _code(exc) + + metrics: dict[str, BucketMetrics] = {} + errors: dict[str, str] = {} + with ThreadPoolExecutor(max_workers=8) as pool: + for name, found, error in pool.map(size, buckets): + if found is not None: + metrics[name] = found + self._count_metrics(_metrics_read(found), _METRIC_PRICE) + if error: + errors[f"{name}:metrics"] = error + return metrics, errors + + def _triggers(self, bucket: str, function: str, since: str, until: str, region: str) -> dict[str, Any]: + """s3_triggers: the links for a bucket, a function or both, each checked from both ends (see its docstring).""" + s3, lf = _s3_module(), _lambda_module() + lam_kit = self.peer("lambda") + if lam_kit is None: + raise ValueError("s3_triggers needs the Lambda toolkit too: AgentView(toolkits=['lambda', 's3'])") + lam, core = lam_kit.analyzer, self.analyzer + bucket, function = str(bucket or "").strip(), str(function or "").strip() + if not bucket and not function: + raise ValueError("s3_triggers needs a bucket, a function, or both") + start, end = _between(since or "24h", until) + name = s3.parse_s3_uri(bucket)[0] if bucket else "" + fn = lam.function(function, region=region or None) if function else None # missing: its close names + errors: dict[str, str] = {} + notes: list[tuple[str, str]] = [] + others: list[EventNotification] = [] + pairs: list[tuple[str, EventNotification]] = [] + searched: list[str] = [] + if name: + for n in core.notifications(name): # a missing bucket fails here: its close names + if n.target != "Lambda function": + others.append(n) + elif fn is None or _points_at(n, fn): + pairs.append((name, n)) + elif fn is not None: + searched, named, notes = self._buckets_for(fn, errors) + for candidate in searched: + try: + pairs += [(candidate, n) for n in core.notifications(candidate) if _points_at(n, fn)] + except (ClientError, BotoCoreError) as exc: + errors[f"{candidate}:notifications"] = _code(exc) + for leftover in sorted(set(named) - {b for b, _ in pairs}): + notes.append(("info", f"{fn.name}'s resource policy lets bucket {leftover} invoke it, but " + f"{leftover} has no event notification that does: a leftover permission, or a " + "notification that was removed.")) + + functions: dict[str, Function | None] = {} # notification ARN -> the function, None when it isn't there + unknown: set[str] = set() # ARNs that couldn't be looked up + policies: dict[tuple[str, str, str | None], list[Any] | None] = {} + listings: dict[tuple[str, str], list[ObjectInfo] | None] = {} + links: list[TriggerLink] = [] + cap = self.budget.s3_keys + for where, n in pairs: + arn = n.arn or "" + link = TriggerLink(where, n, account=arn.split(":")[4] if arn.count(":") >= 5 else None) + if arn not in functions: + try: + functions[arn] = lam.function(arn) + except (ClientError, BotoCoreError) as exc: + functions[arn] = None + if _code(exc) != "ResourceNotFoundException": + errors[f"{n.name}:function"] = _code(exc) + unknown.add(arn) + target = functions[arn] + link.exists = None if arn in unknown else target is not None + if target is not None: + qualifier = lf.parse_function_ref(arn)[1] + key = (target.name, target.region, qualifier) + if key not in policies: + try: + policies[key] = lf.parse_policy(lam.policy(target.name, target.region, qualifier=qualifier)) + except (ClientError, BotoCoreError) as exc: + errors[f"{target.name}:policy_of_function"] = _code(exc) + policies[key] = None + if policies[key] is not None: + link.allowed = _s3_may_invoke(policies[key], where) + if link.exists is not False and n.on_created(): + listed = (where, n.prefix) + if listed not in listings: + try: + listings[listed] = list(core.iter_objects(s3.s3_uri(where, n.prefix), limit=cap + 1)) + except (ClientError, BotoCoreError) as exc: + errors[f"{where}:list"] = _code(exc) + listings[listed] = None + if listings[listed] is not None: + link.arrivals = count_arrivals(listings[listed], n, start, end, limit=cap) + links.append(link) + + live = list({f.arn: f for f in functions.values() if f is not None}.values()) + if live: + try: + numbers, read = lam.metrics(live, since=start, until=end, logs=False) + self._count_metrics(read, lam.prices["metric_request"]) + for link in links: + target = functions.get(link.notification.arn or "") + link.metrics = numbers.get(target.arn) if target is not None else None + except (ClientError, BotoCoreError) as exc: + errors["metrics"] = _code(exc) + + out = summarize_triggers(links, start, end, bucket=name, function=fn.name if fn else "", others=others, + notes=notes, errors=errors, searched=searched) + warned = [link for link in links if any(level == "warn" for level, _ in trigger_findings(link))] + pairs_text = sorted({f"{link.bucket} → {link.function}" for link in links}) + out["_summary"] = (_plural(len(links), "S3 → Lambda link") + (f": {_clip(', '.join(pairs_text), 120)}" + if links else "") + + (f"; {_plural(len(warned), 'link')} with warnings" if warned else "")) + nxt: list[tuple[str, str]] = [] + for link in links: + target = functions.get(link.notification.arn or "") + if target is not None and link.metrics is not None and link.metrics.errors: + nxt.append((lam_kit._next_for(target, "errors", since=_iso(start)), "why its runs failed")) + if link.arrivals is not None and link.arrivals.matched: + n = link.notification + nxt.append((self._view_call("find", s3.s3_uri(link.bucket, n.prefix), + pattern=f"*{n.suffix}" if n.suffix else None, + modified_after=_iso(start)), "the files written in the window")) + if target is not None: + nxt.append((lam_kit._next_for(target, "function_info"), "the function, its triggers and settings")) + out["_next"] = nxt + return out + + def _buckets_for(self, fn: Function, errors: dict[str, str]) -> tuple[list[str], list[str], + list[tuple[str, str]]]: + """(the buckets to look through, the buckets its policy names, notes) for a function: the buckets whose + notifications may invoke it. Those its resource policy names for S3; or, when the policy lets S3 invoke it + for any bucket (or for names matching a pattern, or doesn't let S3 at all), the buckets in its region, at + most _SEARCHED_BUCKETS.""" + lf = _lambda_module() + notes: list[tuple[str, str]] = [] + try: + triggers = lf.parse_policy(self.peer("lambda").analyzer.policy(fn.name, fn.region)) + except (ClientError, BotoCoreError) as exc: + errors[f"{fn.name}:policy_of_function"] = _code(exc) + triggers = None + for_s3 = [t for t in triggers or [] if t.kind == "S3 bucket" or t.principal == "s3.amazonaws.com"] + wild = set("*?[") + named = list(dict.fromkeys(t.arn.split(":::", 1)[1] for t in for_s3 + if t.arn and t.arn.startswith("arn:aws:s3:::") and not set(t.arn) & wild)) + patterns = [t.arn for t in for_s3 if t.arn and set(t.arn) & wild] + anywhere = triggers is None or not for_s3 or any(t.arn is None for t in for_s3) + if triggers is not None and not for_s3: + notes.append(("warn", f"{fn.name}'s resource policy has no statement that lets S3 invoke it, so no " + f"bucket's events can reach it. The buckets in {fn.region} were checked for " + "notifications that point at it anyway.")) + if named and not patterns and not anywhere: + return named, named, notes + try: + buckets = self.analyzer.list_buckets() + except (ClientError, BotoCoreError) as exc: + errors["buckets"] = _code(exc) + return named, named, notes + here = [b.name for b in buckets if b.region == fn.region and ( + anywhere or any(fnmatch.fnmatchcase(f"arn:aws:s3:::{b.name}", p) for p in patterns))] + candidates = list(dict.fromkeys(named + here)) + if len(candidates) > _SEARCHED_BUCKETS: + notes.append(("info", f"Only {_SEARCHED_BUCKETS} of the {len(candidates)} buckets in {fn.region} were " + "checked for notifications that invoke it: name the bucket to check that one.")) + candidates = candidates[:_SEARCHED_BUCKETS] + return candidates, named, notes + + def not_found(self, exc: ClientError, arguments: dict[str, Any]) -> dict[str, Any] | None: + """A tool's answer when a bucket, a file or a function isn't there: the closest names.""" + s3 = _s3_module() + code = _error_code(exc) + if code == "ResourceNotFoundException" and arguments.get("function"): + lam = self.peer("lambda") + return lam.not_found(exc, arguments) if lam is not None else None + location = str(arguments.get("bucket") or arguments.get("location") or "").strip() + if not location: + return None + bucket, key = s3.parse_s3_uri(location) + core = self.analyzer + if code == "NoSuchBucket": + try: + names = [b.name for b in core.list_buckets(with_region=False)] + except (ClientError, BotoCoreError): + names = [] + return {"error": f"No bucket named {bucket!r} (that this account can see). Names are case-sensitive.", + "close_names": difflib.get_close_matches(bucket, names, n=3, cutoff=0.5), + "hint": "Use one of close_names, or find_buckets to list them."} + if code in ("404", "NoSuchKey", "NotFound") and getattr(exc, "operation_name", "") == "HeadObject": + try: + listing = core.ls(s3.s3_uri(bucket, s3.base_prefix(key)), limit=1000) + except (ClientError, BotoCoreError): + listing = None + if listing is not None and key.rstrip("/") + "/" in listing.folders: + return {"error": f"s3://{bucket}/{key} is a folder, not a file.", + "hint": "list_folder or find_files show what's in it."} + names = [o.key for o in listing.objects] if listing is not None else [] + return {"error": f"No file at s3://{bucket}/{key}. Keys are case-sensitive.", + "close_keys": difflib.get_close_matches(key, names, n=3, cutoff=0.6), + "hint": "Use one of close_keys, or list_folder / find_files to see what's there."} + return None + + +def _lambda_module() -> Any: + """aws_analyzer.lambda_functions, which s3_triggers reads Lambda's policies with (loaded when it first runs).""" + from ... import lambda_functions + + return lambda_functions + + +def _metrics_read(metrics: BucketMetrics) -> int: + """How many CloudWatch metrics bucket_metrics() read: one per storage type it found, and the object count.""" + return len(metrics.size_by_storage_type) + (metrics.object_count is not None) + diff --git a/src/aws_analyzer/agent/view.py b/src/aws_analyzer/agent/view.py index 0e473b3..c21062b 100644 --- a/src/aws_analyzer/agent/view.py +++ b/src/aws_analyzer/agent/view.py @@ -51,7 +51,8 @@ def _render_html(blocks: list[Any], max_rows: int) -> str: def _coverage_text(call: ToolCall) -> str: """'2026-10-15 18:00 to 2026-10-16 08:00 IST · us-east-1 · cut short' for the table of what it checked: the window - in the zone reports show times in (set_timezone()), the regions, and whether it was cut short.""" + in the zone reports show times in (set_timezone()), the regions, the S3 keys it read, and whether it was cut + short.""" cov = call.coverage parts = [] window = cov.get("window") @@ -61,6 +62,8 @@ def _coverage_text(call: ToolCall) -> str: where = cov.get("regions") or cov.get("region") if where: parts.append(", ".join(where) if isinstance(where, (list, tuple)) else str(where)) + if cov.get("scanned"): + parts.append(f"{int(cov['scanned']):,} keys read") if cov.get("cut_short"): parts.append("cut short") return " · ".join(parts) or "-" @@ -107,6 +110,7 @@ class AgentView(_BaseView): _START = ( ("ask('Which Lambda failed last night?')", "the answer, what it checked, and what to run next"), ("ask('Why did it fail?')", "a follow-up: it remembers the conversation"), + ("ask('Did the files uploaded to acme-raw last night get processed?')", "S3 and Lambda together"), ("tools()", "what the agent can call, its model and its budget"), ) _PROGRESS = ("Thinking", "steps") @@ -139,9 +143,9 @@ def _explain(self, code: str, message: str) -> str: @_friendly_errors def ask(self, question: str, *, new: bool = False) -> None: - """The agent's answer to a question about your AWS account (Lambda, for now), then what it checked to get it: - each tool call with its time window and regions, what it couldn't read or was refused, the AWS reads, the - tokens and what it cost, and the LambdaView commands to look closer. Follow-up questions continue the + """The agent's answer to a question about your AWS account (Lambda and S3, for now), then what it checked to + get it: each tool call with its time window and regions, what it couldn't read or was refused, the AWS reads, + the tokens and what it cost, and the LambdaView and S3View commands to look closer. Follow-up questions continue the conversation; new=True starts a new one. The kernel's stop button stops it, and the report shows what it found until then.""" with self._progress() as tick: @@ -190,9 +194,13 @@ def _answer_blocks(self, answer: AgentAnswer) -> list[Any]: else: basis.append(f"Model cost is an estimate: {answer.input_tokens:,} input and {answer.output_tokens:,} " "output tokens at the prices you passed in model_prices=.") - if answer.metrics_read: - basis.append(f"It read {answer.metrics_read:,} CloudWatch metrics ({human_money(answer.aws_cost)}); its " - "other AWS reads are free.") + billed = [f"{answer.metrics_read:,} CloudWatch metrics"] if answer.metrics_read else [] + requests = sum(n for op, n in answer.aws_calls.items() if op.startswith("s3:")) + if requests: + billed.append(f"{_plural(requests, 'S3 request')}") + if billed: + basis.append(f"Its AWS reads cost {human_money(answer.aws_cost)} ({' and '.join(billed)}); the rest are " + "free.") blocks.append(_Note(" ".join(basis))) if answer.error: blocks.append(_Note(answer.error, "warn")) @@ -268,6 +276,7 @@ def tools(self) -> None: "the same (only when model_prices= says what its tokens cost)"], ["Steps", f"{budget.steps:,}", "the agent stops (LangGraph's recursion limit)"], ["Log window", f"{budget.log_days:g} days", "a longer log read is refused; CloudWatch's counts still work"], + ["S3 keys", f"{budget.s3_keys:,}", "a listing stops there and says so: its counts cover the keys it read"], ], title="Budget", sortable=False)) never = " ".join(t.privacy for t in core.toolkits if t.privacy) blocks.append(_Note( @@ -276,6 +285,7 @@ def tools(self) -> None: + f" The model is your Azure OpenAI deployment ({model}), outside AWS.")) blocks.append(_Next([ ("ask('Which Lambda failed last night?')", "an answer, and what it checked"), + ("ask('Which Lambda processes files uploaded to acme-raw?')", "S3 and Lambda together"), ("AgentView(budget={'tool_calls': 50, 'log_days': 7})", "a bigger budget per question"), ])) self._show(blocks) diff --git a/src/aws_analyzer/s3.py b/src/aws_analyzer/s3.py index e2d8cae..705362d 100644 --- a/src/aws_analyzer/s3.py +++ b/src/aws_analyzer/s3.py @@ -1892,6 +1892,57 @@ def public(self) -> bool: return self.effect == "Allow" and self.anyone and not self.restricted +@dataclass +class EventNotification: + """One of a bucket's event notifications (see parse_notifications): which events, for which keys, go where.""" + + target: str # 'Lambda function' | 'SQS queue' | 'SNS topic' | 'EventBridge' + arn: str | None = None # the function, queue or topic; None for EventBridge (every event goes to its default bus) + events: list[str] = field(default_factory=list) # 's3:ObjectCreated:*', 's3:ObjectCreated:Put', ... + prefix: str = "" # only keys that start with this ('' = any key); S3 compares case-sensitively + suffix: str = "" # only keys that end with this ('' = any key); case-sensitive too + id: str = "" + + @property + def name(self) -> str: + """The function, queue or topic's name from its ARN: 'orders-etl'; 'EventBridge' for EventBridge.""" + parts = (self.arn or "").split(":") + if self.target == "Lambda function" and len(parts) > 6: + return parts[6] + return parts[5] if len(parts) > 5 else self.arn or self.target + + @property + def region(self) -> str | None: + """The region in its ARN (a Lambda function or queue has to be in the bucket's own region).""" + parts = (self.arn or "").split(":") + return parts[3] or None if len(parts) > 3 else None + + def matches(self, key: str) -> bool: + """Whether an object with this key is covered by its prefix and suffix filter.""" + return key.startswith(self.prefix) and key.endswith(self.suffix) + + def on_created(self, multipart: bool | None = None) -> bool: + """Whether a new object sends it. multipart=True: an object uploaded in parts, which sends + s3:ObjectCreated:CompleteMultipartUpload; False: one uploaded whole (a PUT, a POST form or a copy); None: + any new object.""" + events = set(self.events) + if self.target == "EventBridge" or "s3:ObjectCreated:*" in events: + return True + if multipart is None: + return any(e.startswith("s3:ObjectCreated:") for e in events) + if multipart: + return "s3:ObjectCreated:CompleteMultipartUpload" in events + return bool(events & {"s3:ObjectCreated:Put", "s3:ObjectCreated:Post", "s3:ObjectCreated:Copy"}) + + def describe(self) -> str: + """In plain English: "new objects under 'incoming/' ending in '.csv' -> Lambda function orders-etl".""" + what = _event_words(self.events) if self.target != "EventBridge" else "every event" + keys = " ".join(filter(None, [f"under '{self.prefix}'" if self.prefix else "", + f"ending in '{self.suffix}'" if self.suffix else ""])) + return f"{what}{' ' + keys if keys else ''} -> {self.target}" + ( + f" {self.name}" if self.target != "EventBridge" else " (the account's default event bus)") + + @dataclass class BucketReport: """One bucket in the all-buckets overview.""" @@ -3803,6 +3854,55 @@ def policy_findings(statements: list[PolicyStatement]) -> list[tuple[str, str]]: return found +_NOTIFICATION_TARGETS = ( # GetBucketNotificationConfiguration's lists -> (target, the key of its ARN) + ("LambdaFunctionConfigurations", "Lambda function", "LambdaFunctionArn"), + ("QueueConfigurations", "SQS queue", "QueueArn"), + ("TopicConfigurations", "SNS topic", "TopicArn"), +) +_EVENT_WORDS = { # S3 event types in plain English (EventNotification.describe) + "s3:ObjectCreated:*": "new objects", + "s3:ObjectCreated:Put": "PUT uploads", + "s3:ObjectCreated:Post": "form (POST) uploads", + "s3:ObjectCreated:Copy": "copies", + "s3:ObjectCreated:CompleteMultipartUpload": "multipart uploads", + "s3:ObjectRemoved:*": "deletions", + "s3:ObjectRemoved:Delete": "deletions", + "s3:ObjectRemoved:DeleteMarkerCreated": "new delete markers", + "s3:ObjectRestore:*": "restores from Glacier", + "s3:ObjectRestore:Post": "restores started", + "s3:ObjectRestore:Completed": "restores completed", + "s3:LifecycleExpiration:*": "lifecycle expirations", + "s3:LifecycleTransition": "lifecycle transitions", + "s3:ObjectTagging:*": "tag changes", + "s3:Replication:*": "replication events", +} + + +def _event_words(events: Iterable[str]) -> str: + """['s3:ObjectCreated:Put', 's3:ObjectRemoved:*'] -> 'PUT uploads and deletions'.""" + words = list(dict.fromkeys(_EVENT_WORDS.get(e, e.removeprefix("s3:")) for e in events)) or ["no events"] + return words[0] if len(words) == 1 else ", ".join(words[:-1]) + " and " + words[-1] + + +def parse_notifications(config: dict[str, Any] | None) -> list[EventNotification]: + """A bucket's notification configuration (GetBucketNotificationConfiguration) as a list: each Lambda function, + SQS queue and SNS topic it sends events to, with the events and the key prefix and suffix they're limited to, + and EventBridge when it's turned on (every event then goes to the account's default event bus, where its rules + pick them).""" + config = config or {} + found: list[EventNotification] = [] + for section, target, arn_key in _NOTIFICATION_TARGETS: + for entry in config.get(section) or []: + rules = ((entry.get("Filter") or {}).get("Key") or {}).get("FilterRules") or [] + by_name = {str(rule.get("Name", "")).lower(): str(rule.get("Value", "")) for rule in rules} + found.append(EventNotification(target, entry.get(arn_key), list(entry.get("Events") or []), + by_name.get("prefix", ""), by_name.get("suffix", ""), + entry.get("Id") or "")) + if "EventBridgeConfiguration" in config: + found.append(EventNotification("EventBridge")) + return found + + # ---- A PDF's text laid out to read (pdf_flow): headings, paragraphs and lists from where its lines sit _PDF_BULLET = re.compile(r"([•◦▪▫●○■□‣⁃∙·\x7f-]|[-–—*])\s+(?=\S)") # '• ', Symbol / Wingdings bullets @@ -5400,6 +5500,15 @@ def bucket_policy(self, bucket: str) -> dict | None: return None raise + def notifications(self, bucket: str) -> list[EventNotification]: + """The bucket's event notifications: the Lambda functions, SQS queues and SNS topics it sends which events + to, for which keys, and whether EventBridge gets every event (see parse_notifications). Needs + s3:GetBucketNotification.""" + bucket, _ = parse_s3_uri(bucket) + return parse_notifications( + self._client_for(bucket).get_bucket_notification_configuration(Bucket=bucket) + ) + def versioning_status(self, bucket: str) -> str: """'Enabled', 'Suspended' or 'Disabled'.""" bucket, _ = parse_s3_uri(bucket) diff --git a/tests/test_agent.py b/tests/test_agent.py index be2d3f8..dec1886 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -20,20 +20,26 @@ Budget, LambdaToolkit, ReadOnlyError, + S3Toolkit, Toolkit, ToolCall, + TriggerLink, answer_cost, answer_findings, answer_next, asked_at, + count_arrivals, explain_model_error, hide_secrets, summarize_errors, + summarize_file, summarize_health, system_prompt, + trigger_findings, ) from aws_analyzer.lambda_functions import ErrorGroup, ErrorReport, Function, FunctionMetrics -from test_lambda_functions import CODE, OTHER, REGION, RID, create, lambda_clients, put_logs +from aws_analyzer.s3 import EventNotification, ObjectInfo +from test_lambda_functions import ACCOUNT, CODE, OTHER, REGION, RID, create, lambda_clients, put_logs NOW = datetime(2026, 10, 16, 3, 42, tzinfo=timezone.utc) @@ -183,7 +189,7 @@ def test_answer_findings_say_where_it_stopped_and_what_it_missed(): assert "stopped at its line limit: it covers 2026-10-16 03:42 UTC" in text assert answer_findings(AgentAnswer("q", text="hi")) == [ ("info", "It answered without calling a tool, so nothing in the answer comes from your account. Ask about " - "your functions, or a time window.")] + "your functions or buckets, or a time window.")] def test_answer_next_prefers_the_latest_calls_without_repeats(): @@ -294,7 +300,8 @@ def test_toolkits_says_what_it_takes(): with pytest.raises(TypeError, match="toolkits= takes names"): AgentAnalyzer(region=REGION, toolkits=[5]) with pytest.raises(KeyError, match="no 's3' toolkit; it has lambda"): - AgentAnalyzer(region=REGION).toolkit("s3") + AgentAnalyzer(region=REGION, toolkits=["lambda"]).toolkit("s3") + assert [t.name for t in AgentAnalyzer(region=REGION).toolkits] == ["lambda", "s3"] # the default: both notes = Notes() AgentAnalyzer(region=REGION, toolkits=[notes]) with pytest.raises(ValueError, match="already belongs to another AgentAnalyzer"): @@ -390,6 +397,8 @@ def test_tools_answer_from_lambda_cloudwatch_and_logs(core): found = tool(core, "find_functions")(regions=f"{REGION},{OTHER}") assert found["count"] == 3 and {f["name"] for f in found["functions"]} == {"etl-nightly", "api-handler", "eu-fn"} + lam = core.toolkit("lambda") # the next step leaves out what wasn't given, so the command's defaults apply + assert lam._view_call("functions", None, regions=None) == "LambdaView().functions()" def test_a_missing_function_lists_close_names(core): @@ -421,6 +430,238 @@ def test_the_guard_refuses_anything_but_a_read(core): assert boto3.client("lambda", region_name=REGION).get_function(FunctionName="etl-nightly") # still there +# ---- the S3 toolkit, and S3 with Lambda (pure, then on moto) + + +ETL_ARN = f"arn:aws:lambda:{REGION}:{ACCOUNT}:function:etl-nightly" + + +def put_only(prefix="in/", suffix=".csv", arn=ETL_ARN, events=("s3:ObjectCreated:Put",)): + return EventNotification("Lambda function", arn, list(events), prefix, suffix, "etl") + + +def s3_object(key, minutes_ago, etag="abc", size=10): + return ObjectInfo("raw", key, size, NOW - timedelta(minutes=minutes_ago), etag=etag) + + +def test_count_arrivals_says_which_files_the_notification_missed(): + objects = [s3_object("in/a.csv", 10), s3_object("in/b.csv", 20, etag="abc-3"), s3_object("in/c.CSV", 5), + s3_object("in/old.csv", 600), s3_object("in/notes.txt", 1), s3_object("in/", 0, size=0)] + a = count_arrivals(objects, put_only(), NOW - timedelta(hours=1), NOW) + assert (a.scanned, a.matched, a.sent, a.multipart_missed, a.whole_missed) == (6, 2, 1, 1, 0) + assert a.case_missed == ["in/c.CSV"] and a.missed_keys == ["in/b.csv"] and [o.key for o in a.newest] == ["in/a.csv"] + assert a.last_match.key == "in/a.csv" and not a.cut_short + every = count_arrivals(objects, put_only(events=["s3:ObjectCreated:*"]), NOW - timedelta(hours=1), NOW) + assert (every.sent, every.multipart_missed) == (2, 0) + assert count_arrivals(objects, put_only(), NOW - timedelta(hours=1), NOW, limit=2).cut_short + + +def test_trigger_findings_say_what_breaks_the_link_and_what_to_do(): + window = (NOW - timedelta(hours=1), NOW) + a = count_arrivals([s3_object("in/a.csv", 10), s3_object("in/b.csv", 20, etag="abc-3"), + s3_object("in/c.CSV", 5)], put_only(), *window) + quiet = FunctionMetrics("etl-nightly", 0.04, 3600) + text = " ".join(m for _, m in trigger_findings(TriggerLink("raw", put_only(), True, False, a, quiet, ACCOUNT))) + assert ("aws lambda add-permission --function-name etl-nightly --statement-id s3-raw --action " + "lambda:InvokeFunction --principal s3.amazonaws.com --source-arn arn:aws:s3:::raw --source-account " + f"{ACCOUNT} --region {REGION}") in text + assert "1 file under 'in/' ending in '.csv' written in the window was uploaded in parts" in text + assert "ends in '.CSV', which the suffix filter '.csv' doesn't match (S3 compares case-sensitively): it never" in text + assert "counted no runs" not in text # the missing permission already explains it + allowed = trigger_findings(TriggerLink("raw", put_only(), True, True, a, quiet, ACCOUNT)) + assert any("CloudWatch counted no runs of it" in m for _, m in allowed) + failed = FunctionMetrics("etl-nightly", 0.04, 3600, invocations=5, errors=2) + assert any("etl-nightly failed 2 times in 5 runs" in m and "function_errors('etl-nightly')" in m + for _, m in trigger_findings(TriggerLink("raw", put_only(), True, True, a, failed))) + [(level, gone)] = trigger_findings(TriggerLink("raw", put_only(), exists=False)) + assert level == "warn" and gone.startswith("Bucket raw sends s3:ObjectCreated:Put for files under 'in/' ending " + "in '.csv' to Lambda function etl-nightly, which isn't there in us-east-1") + nothing = count_arrivals([s3_object("in/a.csv", 600)], put_only(), *window) + [(_, why)] = trigger_findings(TriggerLink("raw", put_only(), True, True, nothing)) + assert "so S3 had nothing to send etl-nightly: if it didn't run, that's why" in why + assert "The newest file it covers is in/a.csv, written 2026-10-15T17:42Z" in why + + +def test_summarize_file_says_what_stops_a_reader_and_hides_secrets(): + out = summarize_file({"uri": "s3://raw/a.csv", "size": 2048, "storage_class": "GLACIER", "encryption": "aws:kms", + "kms_key": "arn:aws:kms:us-east-1:1:key/k1", "metadata": {"db_password": "hunter2"}, + "tags": None}) + assert out["size"] == "2.0 KB" and out["metadata"] == {"db_password": "[hidden]"} + assert "archived (GLACIER)" in out["notes"][0] and "kms:Decrypt on that key" in out["notes"][1] + assert out["tags"] == "not read: needs s3:GetObjectTagging" + + +S3_POLICY = {"Version": "2012-10-17", "Statement": [ + {"Sid": "s3-acme-raw", "Effect": "Allow", "Principal": {"Service": "s3.amazonaws.com"}, + "Action": "lambda:InvokeFunction", "Resource": ETL_ARN, + "Condition": {"ArnLike": {"AWS:SourceArn": "arn:aws:s3:::acme-raw"}, "StringEquals": {"AWS:SourceAccount": ACCOUNT}}}, + {"Sid": "s3-acme-old", "Effect": "Allow", "Principal": {"Service": "s3.amazonaws.com"}, + "Action": "lambda:InvokeFunction", "Resource": ETL_ARN, + "Condition": {"ArnLike": {"AWS:SourceArn": "arn:aws:s3:::acme-old"}}}, +]} + + +def notify(bucket, arn, events=("s3:ObjectCreated:Put",), prefix="incoming/", suffix=".csv"): + rules = [{"Name": "prefix", "Value": prefix}, {"Name": "suffix", "Value": suffix}] + boto3.client("s3", region_name=REGION).put_bucket_notification_configuration( + Bucket=bucket, NotificationConfiguration={"LambdaFunctionConfigurations": [ + {"Id": "etl", "LambdaFunctionArn": arn, "Events": list(events), "Filter": {"Key": {"FilterRules": rules}}}]}) + + +@pytest.fixture +def buckets(seeded): + """acme-raw: three files under incoming/ written just now (a .csv uploaded whole, a .csv uploaded in parts and a + .CSV) and a README, and a notification that sends PUT uploads of incoming/*.csv to etl-nightly, whose policy lets + it (and acme-old, which sends it nothing); acme-ghost sends events to a function that isn't there; acme-drop to + api-handler, which doesn't let S3 invoke it; acme-reports is in eu-west-1.""" + s3 = boto3.client("s3", region_name=REGION) + for name in ("acme-raw", "acme-ghost", "acme-old", "acme-drop"): + s3.create_bucket(Bucket=name) + s3.put_object(Bucket="acme-raw", Key="incoming/orders-1.csv", Body=b"id,amount\n1,SECRET-ROW\n") + s3.put_object(Bucket="acme-raw", Key="incoming/orders-2.CSV", Body=b"id\n2\n") + upload = s3.create_multipart_upload(Bucket="acme-raw", Key="incoming/orders-big.csv") + part = s3.upload_part(Bucket="acme-raw", Key="incoming/orders-big.csv", PartNumber=1, + UploadId=upload["UploadId"], Body=b"id\n3\n") + s3.complete_multipart_upload(Bucket="acme-raw", Key="incoming/orders-big.csv", UploadId=upload["UploadId"], + MultipartUpload={"Parts": [{"ETag": part["ETag"], "PartNumber": 1}]}) + s3.put_object(Bucket="acme-raw", Key="README.md", Body=b"# raw", Metadata={"owner": "data-team", + "api-key": "sk-123456"}) + notify("acme-raw", ETL_ARN) # after the uploads: moto would try to run the function for each + notify("acme-ghost", f"arn:aws:lambda:{REGION}:{ACCOUNT}:function:old-loader", events=["s3:ObjectCreated:*"]) + notify("acme-drop", f"arn:aws:lambda:{REGION}:{ACCOUNT}:function:api-handler") + boto3.client("s3", region_name=OTHER).create_bucket(Bucket="acme-reports", + CreateBucketConfiguration={"LocationConstraint": OTHER}) + + +def with_s3(chat_model=None, **settings): + return make(chat_model, clients={"policies": {"etl-nightly": S3_POLICY}}, **settings) + + +def test_s3_tools_read_buckets_folders_and_files_but_never_their_contents(buckets): + core = with_s3() + found = tool(core, "find_buckets")() + assert {b["name"] for b in found["buckets"]} == {"acme-raw", "acme-ghost", "acme-old", "acme-drop", "acme-reports"} + assert next(b for b in found["buckets"] if b["name"] == "acme-reports")["region"] == OTHER + sized = tool(core, "find_buckets")(match="acme-r*", sizes=True) # moto keeps no storage metrics: no numbers + assert sized["count"] == 2 and "sizes_note" in sized + + details = tool(core, "bucket_details")("acme-raw") + assert details["event_notifications"][0]["sends"] == ( + "PUT uploads under 'incoming/' ending in '.csv' -> Lambda function etl-nightly") + assert details["region"] == REGION and details["findings"] + + listing = tool(core, "list_folder")("acme-raw") + assert listing["folders"] == ["incoming/"] and [f["key"] for f in listing["files_newest_first"]] == ["README.md"] + + files = tool(core, "find_files")("s3://acme-raw/incoming/", match="*.csv", since="1h") + assert files["matched"] == 2 and files["scanned"] == 3 and files["cut_short"] is False + assert {f["key"] for f in files["newest_first"]} == {"incoming/orders-1.csv", "incoming/orders-big.csv"} + assert next(f for f in files["newest_first"] if f["key"].endswith("big.csv"))["multipart"] is True + assert files["window"]["hours"] == 1.0 + + summary = tool(core, "folder_summary")("acme-raw") + assert summary["files"] == 4 and summary["cut_short"] is False + + info = tool(core, "file_info")("s3://acme-raw/README.md") + assert info["metadata"] == {"owner": "data-team", "api-key": "[hidden]"} and info["size"] == "5 B" + + everything = json.dumps([found, details, listing, files, summary, info]) + assert "SECRET-ROW" not in everything and "sk-123456" not in everything + + +def test_s3_triggers_joins_a_bucket_to_the_function_it_invokes(buckets): + core = with_s3() + out = tool(core, "s3_triggers")(bucket="acme-raw", since="2h") + [link] = out["links"] + assert (link["function"], link["function_exists"], link["s3_may_invoke"]) == ("etl-nightly", True, True) + files = link["files_in_window"] + assert (files["covered_by_filter"], files["invoked_it"], files["missed_by_upload_kind"], + files["missed_by_case"]) == (2, 1, 1, 1) + assert link["function_in_window"] == {"runs": 1000, "failed": 40, "throttled": 3} + text = " ".join(f["message"] for f in out["findings"]) + assert "was uploaded in parts (multipart)" in text and "incoming/orders-big.csv" in text + assert "ends in '.CSV', which the suffix filter '.csv' doesn't match" in text + assert "etl-nightly failed 40 times in 1,000 runs" in text + assert out["window"]["hours"] == 2.0 and out["scanned"] == 3 and json.dumps(out) # the keys under incoming/ + + past = tool(core, "s3_triggers")(bucket="acme-raw", since="2026-01-01T00:00Z", until="2026-01-02T00:00Z") + text = " ".join(f["message"] for f in past["findings"]) + assert "No file under 'incoming/' ending in '.csv' in bucket acme-raw was written in the window" in text + assert "The newest file it covers is incoming/orders-" in text and "failed" not in text + + +def test_s3_triggers_finds_the_buckets_that_invoke_a_function(buckets): + core = with_s3() + out = tool(core, "s3_triggers")(function="etl-nightly", since="2h") + assert [link["bucket"] for link in out["links"]] == ["acme-raw"] + assert out["buckets_checked"] == ["acme-raw", "acme-old"] # the two its policy names + assert any("lets bucket acme-old invoke it, but acme-old has no event notification that does" in f["message"] + for f in out["findings"]) + + dropped = tool(core, "s3_triggers")(function="api-handler") # no policy: every bucket in its region is checked + [link] = dropped["links"] + assert (link["bucket"], link["s3_may_invoke"]) == ("acme-drop", False) + text = " ".join(f["message"] for f in dropped["findings"]) + assert "resource policy has no statement that lets S3 invoke it" in text + assert "aws lambda add-permission --function-name api-handler" in text + assert "acme-reports" not in dropped["buckets_checked"] # another region's buckets can't invoke it + + ghost = tool(core, "s3_triggers")(bucket="acme-ghost") + assert ghost["links"][0]["function_exists"] is False + assert "which isn't there in us-east-1" in ghost["findings"][0]["message"] + assert tool(core, "s3_triggers")(bucket="acme-reports")["findings"][0]["message"] == ( + "Bucket acme-reports's event notifications invoke no Lambda function.") + + +def test_s3_tools_name_what_isnt_there(buckets): + core = with_s3() + missing = tool(core, "bucket_details")("acme-rae") + assert missing["close_names"][0] == "acme-raw" and "No bucket named 'acme-rae'" in missing["error"] + gone = tool(core, "file_info")("s3://acme-raw/incoming/orders-3.csv") + assert gone["error"].startswith("No file at s3://acme-raw/incoming/orders-3.csv") + assert gone["close_keys"] and gone["close_keys"][0].startswith("incoming/orders-") + assert "is a folder" in tool(core, "file_info")("s3://acme-raw/incoming")["error"] + nobody = tool(core, "s3_triggers")(function="etl-nightlyy") + assert nobody["close_names"] == ["etl-nightly"] + assert "needs a bucket, a function" in tool(core, "s3_triggers")()["message"] + + +def test_s3_listings_stop_at_the_budget(buckets): + core = with_s3(budget={"s3_keys": 2}) + out = tool(core, "find_files")("acme-raw") + assert out["scanned"] == 2 and out["cut_short"] is True and "Stopped after 2 keys" in out["note"] + assert tool(core, "folder_summary")("acme-raw")["cut_short"] is True + + +def test_the_guard_covers_every_client_the_s3_analyzer_makes(buckets): + core = with_s3() + s3a = core.toolkit("s3").analyzer + for client in (s3a.client, s3a._s3_in(OTHER), s3a.session.client("s3", region_name=REGION)): + with pytest.raises(ReadOnlyError, match="s3:DeleteBucket isn't a read"): + client.delete_bucket(Bucket="acme-old") + assert core._guard.blocked == ["s3:DeleteBucket"] * 3 + for _ in range(3): # a client hooked again (and clients made and dropped call by call) counts each read once + core._guard.watch(s3a.client) + s3a.account_id() + assert core._guard.counts["sts:GetCallerIdentity"] == 1 + assert boto3.client("s3", region_name=REGION).head_bucket(Bucket="acme-old") # still there + assert s3a.head("s3://acme-raw/README.md")["size"] == 5 and core._guard.counts["s3:HeadObject"] == 1 + + +def test_without_lambda_the_s3_toolkit_leaves_out_s3_triggers(buckets): + core = AgentAnalyzer(region=REGION, toolkits=["s3"]) + names = [t.__name__ for t in core.tools()] + assert "s3_triggers" not in names and names[:3] == ["find_buckets", "bucket_details", "list_folder"] + text = core.instructions() + assert text.startswith("You answer questions about the user's AWS account for a data scientist working in a " + "notebook. You cover Amazon S3: which buckets exist") + assert "S3 and Lambda together" not in text and "invoke which Lambda functions" not in text + both = AgentAnalyzer(region=REGION).instructions() + assert "\nS3 and Lambda together\n- For a function that S3 events invoke" in both + assert both.index("\nAWS Lambda\n") < both.index("\nAmazon S3\n") < both.index("How to answer") + assert isinstance(AgentAnalyzer(region=REGION).toolkit("s3"), S3Toolkit) + + # ---- the agent loop, with a scripted model @@ -544,6 +785,30 @@ def test_a_stop_request_ends_the_question_with_what_it_found(seeded): assert answer.stopped == "stopped" and answer.text == "" +def test_ask_about_s3_and_lambda_together(buckets, capsys): + model = scripted( + calls(("s3_triggers", {"bucket": "acme-raw", "since": "2h"})), + calls(("find_files", {"location": "s3://acme-raw/incoming/", "since": "2h"})), + says("1 of the 2 new .csv files ran **etl-nightly**: the multipart upload never triggered it."), + ) + view = AgentView(with_s3(model, budget={"s3_keys": 2}), mode="text", progress="off") + answer = view.core.ask("Were the files uploaded to acme-raw processed?") + assert [c.name for c in answer.calls] == ["s3_triggers", "find_files"] + assert answer.calls[0].summary == "1 S3 → Lambda link: acme-raw → etl-nightly; 1 link with warnings" + assert answer.aws_calls["s3:GetBucketNotificationConfiguration"] == 1 and answer.aws_calls["s3:ListObjectsV2"] + assert answer.aws_cost > answer.metrics_read / 1000 * 0.01 # S3's requests are billed too + assert "s3_triggers" in model.bound[0] and "find_buckets" in model.bound[0] + assert "S3 and Lambda together" in model.prompts[0][0].content + found = " ".join(m for _, m in answer_findings(answer, view.core.budget)) + assert "find_files('s3://acme-raw/incoming/', since='2h') stopped after 2 S3 keys" in found + assert "AgentView(budget={'s3_keys': 10})" in found + view._show(view._answer_blocks(answer)) + out = capsys.readouterr().out + assert "UTC · 2 keys read · cut short" in out and "(5 CloudWatch metrics and 4 S3 requests)" in out + assert "Look closer (aws_analyzer.S3View, aws_analyzer.LambdaView)" in out + assert "S3View().find('s3://acme-raw/incoming/', modified_after=" in out and "None" not in out + + def test_a_failed_model_call_becomes_the_answers_error(seeded): denied = type("AuthenticationError", (Exception,), {"status_code": 401})("Access denied due to invalid key") core = make(scripted(denied)) @@ -660,9 +925,12 @@ def test_transcript_and_new_conversation(view, capsys): def test_tools_report_lists_each_tool_and_the_budget(view, capsys): out = run(capsys, view.tools) for name in ("find_functions", "function_health", "function_errors", "function_logs", "function_performance", - "function_details", "function_code"): + "function_details", "function_code", "find_buckets", "bucket_details", "list_folder", "find_files", + "folder_summary", "file_info", "s3_triggers"): assert name in out assert "logs:FilterLogEvents" in out and "3 days" in out and "outside AWS" in out + assert "Lambda, S3, read-only" in out and "s3:GetBucketNotification" in out and "100,000" in out + assert "File contents are never read" in out def test_help_lists_every_command(): diff --git a/tests/test_s3.py b/tests/test_s3.py index 9d78eec..4d21265 100644 --- a/tests/test_s3.py +++ b/tests/test_s3.py @@ -86,6 +86,7 @@ parse_size, parse_time, pdf_flow, + parse_notifications, pdf_furniture, policy_findings, render_pdf_pages, @@ -1584,6 +1585,29 @@ def test_policy_findings(): assert any("NotPrincipal" in m for _, m in policy_findings([everyone])) +def test_parse_notifications(): + etl, queue, bus = parse_notifications({ + "LambdaFunctionConfigurations": [{ + "Id": "etl", "LambdaFunctionArn": "arn:aws:lambda:us-east-1:123456789012:function:orders-etl:prod", + "Events": ["s3:ObjectCreated:Put"], + "Filter": {"Key": {"FilterRules": [{"Name": "Prefix", "Value": "incoming/"}, + {"Name": "suffix", "Value": ".csv"}]}}}], + "QueueConfigurations": [{"QueueArn": "arn:aws:sqs:eu-west-1:123456789012:audit", + "Events": ["s3:ObjectCreated:*", "s3:ObjectRemoved:*"]}], + "EventBridgeConfiguration": {}, + }) + assert (etl.target, etl.name, etl.region, etl.prefix, etl.suffix) == ( + "Lambda function", "orders-etl", "us-east-1", "incoming/", ".csv") + assert etl.describe() == "PUT uploads under 'incoming/' ending in '.csv' -> Lambda function orders-etl" + assert etl.matches("incoming/a.csv") and not etl.matches("incoming/a.CSV") and not etl.matches("in/a.csv") + assert etl.on_created() and etl.on_created(multipart=False) and not etl.on_created(multipart=True) + assert (queue.name, queue.region) == ("audit", "eu-west-1") and queue.on_created(multipart=True) + assert queue.describe() == "new objects and deletions -> SQS queue audit" + assert bus.target == "EventBridge" and bus.on_created() and bus.arn is None + assert "every event -> EventBridge" in bus.describe() + assert parse_notifications(None) == [] and parse_notifications({"ResponseMetadata": {}}) == [] + + def test_objects_to_df(): df = objects_to_df(sample_objects()[:2]) assert list(df["key"]) == ["p/a/1.csv", "p/a/2.csv"] and "extension" in df.columns @@ -1639,6 +1663,19 @@ def put(key, body, **kwargs): return BUCKET +def test_notifications(bucket): + s3 = S3Analyzer() + assert s3.notifications(bucket) == [] + arn = "arn:aws:lambda:us-east-1:123456789012:function:loader" + boto3.client("s3", region_name="us-east-1").put_bucket_notification_configuration( + Bucket=bucket, NotificationConfiguration={"LambdaFunctionConfigurations": [ + {"LambdaFunctionArn": arn, "Events": ["s3:ObjectCreated:*"], + "Filter": {"Key": {"FilterRules": [{"Name": "prefix", "Value": "raw/"}]}}}]}) + [loader] = s3.notifications(f"s3://{bucket}/raw/") + assert loader.arn == arn and loader.prefix == "raw/" and loader.suffix == "" + assert loader.describe() == "new objects under 'raw/' -> Lambda function loader" + + @pytest.fixture def core(bucket): return S3Analyzer(region="us-east-1")