From 144b3fbebe270bf18f3c76cf8a6c5b10a8629060 Mon Sep 17 00:00:00 2001 From: DoRmAmMu1997 Date: Mon, 28 Sep 2026 15:47:53 +0530 Subject: [PATCH] fix(SEC-005): deliver Agent SDK prompts verbatim claude-agent-sdk 0.2.158 added ClaudeAgentOptions.verbatim_prompts. By default the Claude CLI pre-processes each user message before the model sees it: an @path mention anywhere in the text is expanded into that file's contents, and a leading "/" is dispatched as a slash command. That runs before tools=[], allowed_tools and permission_mode="dontAsk" are consulted, so the existing tool boundary never sees it. The IPO extraction prompt inlines the company name scraped from SEBI listings, so a filing named like "@/etc/passwd" could make the CLI read a local file into the model's context. All four runners (Check Fundamentals, Technical Analysis, 67 Ka Funda, IPO extraction) now set verbatim_prompts=True; the other three prompts carry only app-owned values but share one contract. The option is not feature-detected: an SDK older than the pin raises TypeError, so AI runs fail closed rather than silently dropping the control. The runner tests use permissive fake options classes, so a new test builds the real pinned ClaudeAgentOptions to make an SDK downgrade fail CI. The CLI half (Claude Code 2.1.248+) is satisfied by the CLI 2.1.281 bundled with the 0.2.159 pin, which the SDK prefers over any claude on PATH; the ADR records that an older CLI would ignore the flag with only a warning. Docs: the ai-execution-boundaries ADR gains the decision, rejected options, consequences and verification contract; the security LLD, HLD and the four agent LLDs list the option in their SDK containment contract. Co-Authored-By: Claude Opus 5.5 --- backend/fundamentals/fundamental_agent.py | 5 ++ backend/ipo/agents/financial_extractor.py | 5 ++ backend/sixty_seven/agent.py | 4 ++ backend/technical/technical_agent.py | 6 ++ docs/architecture/ai-execution-boundaries.md | 66 +++++++++++++++++-- .../components/fundamentals-ai.md | 2 +- .../components/ipo-extraction-ai.md | 2 +- docs/architecture/components/security.md | 4 +- .../components/sixty-seven-ka-funda-ai.md | 2 +- .../components/technical-analysis-ai.md | 2 +- docs/architecture/high-level-design.md | 2 +- tests/test_agent_terminal_result.py | 15 +++++ tests/test_fundamental_agent.py | 5 +- tests/test_ipo_financial_extractor.py | 10 ++- tests/test_sixty_seven_agent.py | 3 + tests/test_technical_analysis_agent.py | 5 +- 16 files changed, 124 insertions(+), 14 deletions(-) diff --git a/backend/fundamentals/fundamental_agent.py b/backend/fundamentals/fundamental_agent.py index 0aeae2d..a091ce3 100644 --- a/backend/fundamentals/fundamental_agent.py +++ b/backend/fundamentals/fundamental_agent.py @@ -987,6 +987,11 @@ async def _concall_tool(args: dict[str, Any]) -> dict[str, Any]: # Do not load the user's Claude Code project/user settings or any # CLAUDE.md — this agent's behaviour comes entirely from our prompt. "setting_sources": [], + # Beginner note: hand the prompt to the CLI exactly as written. + # Otherwise the CLI itself would expand an "@path" mention in text + # we interpolate into that file's contents (or run a leading + # "/command"), before the tool allowlist above is ever consulted. + "verbatim_prompts": True, } if self._fast_mode: if ThinkingConfigDisabled is None: diff --git a/backend/ipo/agents/financial_extractor.py b/backend/ipo/agents/financial_extractor.py index 0506009..8dbc5bb 100644 --- a/backend/ipo/agents/financial_extractor.py +++ b/backend/ipo/agents/financial_extractor.py @@ -1744,6 +1744,11 @@ async def _read_tables(args: dict[str, Any]) -> dict[str, Any]: permission_mode="dontAsk", # Behaviour comes entirely from our prompt; never load user settings. setting_sources=[], + # Beginner note: the prompt inlines the company name scraped from SEBI + # listings, which is untrusted. Without verbatim delivery the CLI would + # expand an "@/some/path" inside that name into the file's contents, + # a step that runs before the tool allowlist is ever consulted. + verbatim_prompts=True, ) async def _run() -> str: diff --git a/backend/sixty_seven/agent.py b/backend/sixty_seven/agent.py index 81d6678..f1749f8 100644 --- a/backend/sixty_seven/agent.py +++ b/backend/sixty_seven/agent.py @@ -708,6 +708,10 @@ async def _research_tool(args: dict[str, Any]) -> dict[str, Any]: "allowed_tools": ["mcp__sixty_seven__research_company"], "permission_mode": "dontAsk", "setting_sources": [], + # Beginner note: hand the prompt to the CLI exactly as written, so it + # never expands an "@path" mention in interpolated text into file + # contents (or runs a leading "/command") before the allowlist applies. + "verbatim_prompts": True, } if self._fast_mode: if ThinkingConfigDisabled is None: diff --git a/backend/technical/technical_agent.py b/backend/technical/technical_agent.py index 0cea9b5..f54276e 100644 --- a/backend/technical/technical_agent.py +++ b/backend/technical/technical_agent.py @@ -499,6 +499,12 @@ async def _default_run( "allowed_tools": allowed_tools, "permission_mode": "dontAsk", "setting_sources": [], + # Beginner note: hand the prompt to the CLI exactly as written, so it + # never expands an "@path" mention into file contents (or runs a + # leading "/command") before the tool allowlist is consulted. The + # prompt here is candle numbers only; this keeps all four agents on + # one contract. + "verbatim_prompts": True, } if self._fast_mode: if ThinkingConfigDisabled is None: diff --git a/docs/architecture/ai-execution-boundaries.md b/docs/architecture/ai-execution-boundaries.md index d0c664f..8433978 100644 --- a/docs/architecture/ai-execution-boundaries.md +++ b/docs/architecture/ai-execution-boundaries.md @@ -1,7 +1,7 @@ # ADR — AI execution authorization and Agent SDK containment **Status:** Accepted -**Date:** 2026-09-14 +**Date:** 2026-09-14 (amended 2026-09-28 by SEC-005: verbatim prompt delivery) **Deciders:** repo maintainer (approved modernization Task 4) **Relates to:** [AUTH-003 role model](auth-003-role-model.md) · [shared AI runtime](refactor-003-ai-runtime.md) · [IPO extraction AI](components/ipo-extraction-ai.md) @@ -53,6 +53,33 @@ reviewed `mcp_servers`, exact MCP `allowed_tools`, `permission_mode="dontAsk"`, and `setting_sources=[]` values. The empty built-in selection and the named MCP allowlist are complementary parts of the contract. +### Prompts reach the CLI verbatim (SEC-005) + +All four constructions also set `verbatim_prompts=True` (claude-agent-sdk +0.2.158+). By default the Claude CLI pre-processes each user message before the +model sees it: an `@path` mention anywhere in the text is expanded into that +file's contents (likewise `@server:resource` MCP mentions), and a message that +starts with `/` is dispatched as a slash command. That happens before any tool +call, so `tools=[]`, `allowed_tools`, and `dontAsk` never see it. The IPO +extraction prompt inlines the company name scraped from SEBI listings, so a +filing named like `@/etc/passwd` could otherwise make the CLI read a local file +into the model context. Slash dispatch is not reachable today, because every +prompt starts with fixed app text, but the option removes it too. The other +three prompts carry only app-owned values (symbol, run mode, model name, +candle-derived price facts), and they use the same setting so the contract has +no per-agent exceptions. + +The control has two halves, and they fail differently: + +- **SDK.** The option is not feature-detected. An SDK older than 0.2.158 + rejects the keyword with `TypeError`, so the run fails closed. A test builds + the real `ClaudeAgentOptions` so a pin downgrade fails CI first. +- **CLI.** Claude Code 2.1.248 or later must honor the flag. An older CLI + *ignores* it and the SDK only logs a warning, so prompts are expanded as + before. The pinned SDK 0.2.159 bundles CLI 2.1.281 and prefers that bundled + binary over any `claude` on `PATH`. Do not point `cli_path` at, or deploy + without the bundled binary on, an older CLI. + ### Failed IPO runs return typed receipts before parsing The IPO SDK runner drains the stream, remembers any rejected structured rate @@ -104,6 +131,19 @@ read-only product behavior. Rejected. Permission and tool loading are different SDK controls. Explicit `tools=[]` makes the no-built-ins property reviewable and regression-testable. +### Scrub `@` and `/` from interpolated prompt text instead of `verbatim_prompts` + +Rejected. An app-side escape list would have to track the CLI's expansion +syntax release by release, and it would mangle legitimate names. The SDK option +turns the pre-processing step off at its source. + +### Feature-detect `verbatim_prompts` like `ThinkingConfigDisabled` + +Rejected. Fast mode is an optimization, so a missing toggle can safely fall back +to the default. Verbatim delivery is a security control, and silently running +without it would reopen the pre-tool file-read path. Failing closed on an old +SDK is the intended behavior. + ### Parse failed IPO output if it happens to validate Rejected. Schema validity says nothing about whether the provider completed the @@ -116,11 +156,23 @@ proposal candidate. scan rows or valid cached fundamentals verdicts. - MCP tool availability remains unchanged, while SDK built-in capabilities are explicitly absent for all four agents. +- Prompt text can no longer trigger CLI file expansion or slash commands. The + environment running the agents must carry claude-agent-sdk 0.2.158 or newer + (the `constraints.txt` pin); an older install fails each AI run with + `TypeError` until it is upgraded. The CLI half has no such tripwire (see + above), which is why the bundled binary matters. +- Verbatim turns also skip the CLI's turn-start attachment pass (skill/tool + listings and per-turn reminders arrive after the first tool call instead). + This is expected to be harmless here: instructions come from the system + prompt, `setting_sources=[]` already drops CLAUDE.md, and each agent has at + most three small MCP tools. That expectation is checked only by a live agent + run, because the unit tests replace the SDK with fakes. - IPO batch jobs receive stable, secret-safe operational codes and continue to isolate one document's failure from sibling documents. -- Adding a new agent requires both an explicit built-in `tools` selection and a - test that captures the complete SDK options. A runner that consumes streamed - results must establish terminal success before returning parseable text. +- Adding a new agent requires an explicit built-in `tools` selection, + `verbatim_prompts=True`, and a test that captures the complete SDK options. + A runner that consumes streamed results must establish terminal success + before returning parseable text. ## Verification contract @@ -130,7 +182,11 @@ proposal candidate. verdict, both hidden controls, and denial before agent construction for an initial or forced action. - Each agent test captures `ClaudeAgentOptions` and asserts `tools=[]` beside the - unchanged MCP allowlist, `dontAsk`, and empty setting sources. + unchanged MCP allowlist, `dontAsk`, empty setting sources, and + `verbatim_prompts=True`. +- `tests/test_agent_terminal_result.py` constructs the real pinned + `ClaudeAgentOptions(verbatim_prompts=True)`, because the runner tests' fake + options classes accept any keyword and cannot detect an SDK downgrade. - IPO tests feed valid proposal JSON through every failed SDK/CLI scenario, including assistant-text EOF and an empty stream, and assert a typed code plus an empty proposal table. A successful empty terminal result separately proves diff --git a/docs/architecture/components/fundamentals-ai.md b/docs/architecture/components/fundamentals-ai.md index 233d411..e03e5b0 100644 --- a/docs/architecture/components/fundamentals-ai.md +++ b/docs/architecture/components/fundamentals-ai.md @@ -87,7 +87,7 @@ The mode is chosen by the UI from the row's universe; `_normalize_verdict` **enf | **ContextVars for per-check symbol/refresh** | Cross `asyncio.to_thread` safely; a cached agent can't leak one session's choice into another. | Instance mutable state — cross-session leak. | | **Windows ProactorEventLoop bridge** | The SDK spawns the Claude CLI subprocess; Streamlit/Tornado's SelectorEventLoop can't (`NotImplementedError`). | `asyncio.run()` — fails on Windows. | | **Structured usage-limit detection** | `RateLimitEvent`/`AssistantMessage.error`/HTTP 429 → typed `FundamentalsUsageLimitError` (not string matching); UI shows reset time, cached verdicts keep working. | String matching only — brittle. | -| **`tools=[]` + exact `allowed_tools` + `dontAsk` + `setting_sources=[]`** | The SDK loads no built-in tools, while the two named in-process MCP readers remain callable; the agent ignores user CLAUDE.md. | Rely on SDK defaults — built-in surface can drift. | +| **`tools=[]` + exact `allowed_tools` + `dontAsk` + `setting_sources=[]` + `verbatim_prompts=True`** | The SDK loads no built-in tools, while the two named in-process MCP readers remain callable; the agent ignores user CLAUDE.md, and the CLI never expands `@path` or `/command` text in the prompt ([SEC-005](../ai-execution-boundaries.md)). | Rely on SDK defaults — built-in surface can drift. | | **Current `RUN_SCAN` role checked at display and action boundaries** | A Viewer can read a valid session-cached verdict after demotion or role-lookup fallback, but sees no initial/refresh controls; a queued widget event is denied immediately before agent construction. | Treat retained `scan_cache` or widget state as authority — stale privilege. | | **Bounded validation-retry on malformed output (AI-004)** | `check()` re-runs the agentic loop up to `SCANNER_AI_MAX_ATTEMPTS` (default 2) via the shared `parse_with_retry` when the verdict is unparseable/invalid, then raises `AIValidationError` (a `RuntimeError` the UI already catches). Only parse/validation retries — never SDK/CLI/usage-limit. | Reject on first malformed reply — wastes a recoverable click; retry SDK errors — wastes Agent SDK credit. | diff --git a/docs/architecture/components/ipo-extraction-ai.md b/docs/architecture/components/ipo-extraction-ai.md index a670cd0..7bb324b 100644 --- a/docs/architecture/components/ipo-extraction-ai.md +++ b/docs/architecture/components/ipo-extraction-ai.md @@ -41,7 +41,7 @@ verified cache (IPO-003) -> spawned bounded PDF worker -> parse receipt | Decision | Why | |---|---| | Reuse `ai_runtime` + `ai_validation` (`run_agent_coroutine`, `extract_json_object`, `StrictAIModel`, `parse_with_retry`) | One reviewed implementation of the sync bridge, JSON extraction, strict schemas, and the bounded retry across all four agents. | -| Locked-down `ClaudeAgentOptions` (`tools=[]`, exact MCP `allowed_tools`, `permission_mode="dontAsk"`, `setting_sources=[]`) | The SDK loads no built-ins; only the three in-process prospectus readers exist, and behaviour comes entirely from our prompt. | +| Locked-down `ClaudeAgentOptions` (`tools=[]`, exact MCP `allowed_tools`, `permission_mode="dontAsk"`, `setting_sources=[]`, `verbatim_prompts=True`) | The SDK loads no built-ins; only the three in-process prospectus readers exist, and behaviour comes entirely from our prompt. The prompt inlines the SEBI-scraped company name, so verbatim delivery stops the CLI expanding an `@path` in it into file contents ([SEC-005](../ai-execution-boundaries.md)). | | Successful terminal SDK status is required before output text | Rejected rate/billing events, `ResultMessage.is_error`, missing terminal result, missing CLI, and failed CLI processes become typed errors before JSON parsing. A successful empty terminal result may confirm prior assistant text; EOF cannot enqueue a proposal even when its text validates. | | Values travel as decimal strings | The exact printed digits survive schema validation, host verification, storage, and reconstruction without binary float drift. | | Host parses complete tokens in the original table cell/text span | Formatting-equivalent Indian grouping/currency/whitespace/trailing zeros is accepted, but rounding, substring, and cross-cell matches fail. | diff --git a/docs/architecture/components/security.md b/docs/architecture/components/security.md index cadf2d4..7541370 100644 --- a/docs/architecture/components/security.md +++ b/docs/architecture/components/security.md @@ -5,7 +5,7 @@ | **Component** | Cross-cutting security utilities | | **Source** | [`backend/security/redaction.py`](../../../backend/security/redaction.py), [`backend/security/prompt_injection.py`](../../../backend/security/prompt_injection.py), [`backend/security/__init__.py`](../../../backend/security/__init__.py), [`backend/url_safety.py`](../../../backend/url_safety.py), [`backend/fundamentals/pdf_transport.py`](../../../backend/fundamentals/pdf_transport.py), [`backend/ai_cache_integrity.py`](../../../backend/ai_cache_integrity.py) | | **Layer** | Foundation (leaf utilities, best-effort, never raise on the safety path) | -| **Status** | Stable (SEC-001 URL safety · SEC-002 redaction · PROV-003 AI cache integrity · TEST-003 prompt-injection quarantine) | +| **Status** | Stable (SEC-001 URL safety · SEC-002 redaction · PROV-003 AI cache integrity · TEST-003 prompt-injection quarantine · SEC-005 verbatim agent prompts) | | **Related** | [HLD](../high-level-design.md) · [configuration.md](configuration.md) · [observability.md](observability.md) · [scan-service-and-provenance.md](scan-service-and-provenance.md) · [storage-persistence.md](storage-persistence.md) · [ipo-screener.md](ipo-screener.md) · [fundamentals-ai.md](fundamentals-ai.md) · [technical-analysis-ai.md](technical-analysis-ai.md) · [sixty-seven-ka-funda-ai.md](sixty-seven-ka-funda-ai.md) | ## 1. Purpose & responsibilities @@ -198,6 +198,7 @@ regexes (which raise the false-positive rate and block legitimate evaluations). | **Quarantine fails closed, preserves evidence for audit only** | A hit blocks the *whole* payload to the model and the run yields an error receipt (no verdict, no cache write); the raw evidence survives only in the request-local audit collector. Matches "unsafe outputs fail closed". | Surgically strip the instruction and pass the rest — more bypass-prone; or drop the evidence entirely — no forensics. | | **Prompt-injection failures are non-retryable** | Re-running re-fetches the same poisoned page, so injection raises *outside* the AI-004 validation-retry loop. | Retry on injection — wasted Agent SDK credit, identical outcome. | | **Normalize for matching, never mutate recorded evidence** | Homoglyph/zero-width folding defeats obfuscation without altering the bytes preserved for audit. | Normalize in place — corrupts the forensic record. | +| **Agent prompts are delivered verbatim (SEC-005)** | All four Agent SDK runners set `verbatim_prompts=True`, so the Claude CLI never expands an `@path` mention in prompt text into file contents (nor dispatches a leading `/command`). That pre-processing runs before `tools=[]`, `allowed_tools` and `dontAsk` are consulted, and the IPO extraction prompt inlines a company name scraped from SEBI. An SDK older than 0.2.158 rejects the option (fails closed); a CLI older than 2.1.248 silently ignores it, so the SDK's bundled CLI (2.1.281 at the 0.2.159 pin) must be the one used ([ADR](../ai-execution-boundaries.md)). | Rely on the tool boundary alone — misses the pre-tool file read; scrub `@`/`/` from interpolated text — tracks CLI syntax by hand and mangles names. | ## 5. Failure modes / degradation @@ -216,6 +217,7 @@ regexes (which raise the false-positive rate and block legitimate evaluations). - [`tests/test_ai_cache_integrity.py`](../../../tests/test_ai_cache_integrity.py) — tamper detection, key binding, non-finite rejection. - [`tests/test_prompt_injection.py`](../../../tests/test_prompt_injection.py) — the shared corpus (blocked detected / benign not), Unicode + homoglyph normalization, and the recursive key/list/sibling scan. The two AI agents add their own quarantine + fail-closed tests on top ([fundamentals-ai.md](fundamentals-ai.md), [sixty-seven-ka-funda-ai.md](sixty-seven-ka-funda-ai.md)). - [`tests/test_supply_chain_policy.py`](../../../tests/test_supply_chain_policy.py) — dependency posture. +- Each Agent SDK runner's options test (fundamentals, technical, 67 Ka Funda, IPO extraction) asserts `verbatim_prompts=True`, and [`tests/test_agent_terminal_result.py`](../../../tests/test_agent_terminal_result.py) builds the real pinned `ClaudeAgentOptions` so an SDK downgrade below 0.2.158 fails CI (the runner tests use permissive fakes). - [`tests/test_ipo_sebi_source.py`](../../../tests/test_ipo_sebi_source.py) — source-specific host/redirect checks, retries/timeouts, content type, response and page caps, hostile HTML parsing, and resource closure with fake sessions. diff --git a/docs/architecture/components/sixty-seven-ka-funda-ai.md b/docs/architecture/components/sixty-seven-ka-funda-ai.md index 49102bd..b7e8e6c 100644 --- a/docs/architecture/components/sixty-seven-ka-funda-ai.md +++ b/docs/architecture/components/sixty-seven-ka-funda-ai.md @@ -77,7 +77,7 @@ sequenceDiagram | **Tamper-evident PROV-003 receipt + HMAC cache** | The receipt stores hashed evidence + sanitized URLs + a semantic prompt version (never raw text); the disk verdict cache is HMAC-signed and re-validated (prompt/context hash, model) on read, recomputing on tamper. See [security.md](security.md). | Raw evidence / unsigned cache — leak + forgeable. | | **Every decision is auditable** | `evaluate` always returns a receipt (approved/rejected/error); the screener persists each to `ai_evaluations`. | Persist approvals only — no audit of rejects/errors. | | **Reuses fundamentals SDK plumbing/errors** | One Windows-safe bridge, one usage-limit path. See [fundamentals-ai.md](fundamentals-ai.md). | Duplicate — drift. | -| **`tools=[]` + one exact MCP allowlist entry** | The SDK loads no built-in shell/filesystem tools; only `mcp__sixty_seven__research_company` remains callable under `dontAsk`, with user/project settings disabled. | Rely on SDK defaults — built-in surface can drift. | +| **`tools=[]` + one exact MCP allowlist entry + `verbatim_prompts=True`** | The SDK loads no built-in shell/filesystem tools; only `mcp__sixty_seven__research_company` remains callable under `dontAsk`, with user/project settings disabled and no CLI `@path`/`/command` expansion of the prompt ([SEC-005](../ai-execution-boundaries.md)). | Rely on SDK defaults — built-in surface can drift. | | **Bounded validation-retry, fresh research per attempt (AI-004)** | A malformed/incomplete verdict is re-run up to `SCANNER_AI_MAX_ATTEMPTS` (default 2) via the shared `parse_with_retry`; each attempt **clears the `_RESEARCH_COLLECTOR`** so it re-researches cleanly and the "exactly one payload" invariant still holds. Only verdict-JSON parse/validation retries — **research-evidence failures (missing / malformed / prompt-injection) and SDK/usage-limit errors never retry** (a re-fetch yields the same evidence; injection must not be re-run). | Retry without resetting the collector — mixes payloads, breaks the one-payload invariant; retry research/injection failures — same bad evidence, re-running an injection. | ## 5. Failure modes / degradation diff --git a/docs/architecture/components/technical-analysis-ai.md b/docs/architecture/components/technical-analysis-ai.md index c4c95e2..98bfcc3 100644 --- a/docs/architecture/components/technical-analysis-ai.md +++ b/docs/architecture/components/technical-analysis-ai.md @@ -67,7 +67,7 @@ sequenceDiagram | **Tools, not a candle dump** | Deterministic level/pattern/structure facts beat eyeballing a CSV and keep the verdict reproducible. | Pre-chew everything into the prompt — brittle, no agency. | | **Per-call `TechnicalToolContext` (no agent mutable state)** | The screener confirms candidates in parallel on a shared agent; per-call context is race-free. | Agent instance state — data races. | | **Cache key folds candles + levels + `params`; envelope HMAC-signed** | Tool outputs are pure functions of these; a changed detector setting must invalidate the verdict (not just the date). `::fast` suffix isolates fast-mode verdicts. The stored envelope is HMAC-signed and re-validated on read (recompute on tamper). | Date-only key / unsigned cache — stale or forgeable verdicts. | -| **`tools=[]` + exact `allowed_tools` + `permission_mode="dontAsk"` + `setting_sources=[]`** | The SDK loads no built-ins, while the three deterministic MCP analyzers remain callable. | Rely on SDK defaults — built-in surface can drift. | +| **`tools=[]` + exact `allowed_tools` + `permission_mode="dontAsk"` + `setting_sources=[]` + `verbatim_prompts=True`** | The SDK loads no built-ins, while the three deterministic MCP analyzers remain callable; the CLI never expands `@path` or `/command` text in the prompt ([SEC-005](../ai-execution-boundaries.md)). | Rely on SDK defaults — built-in surface can drift. | | **No untrusted free-text channel (TEST-003 posture)** | Inputs are deterministic OHLC candles + candle-derived levels; the 3 tools are pure functions of those, so no scraped/web/PDF text ever reaches the model. Unlike the fundamental/67 agents this path needs **no** prompt-injection quarantine — and a regression test locks the posture so a future news/sentiment tool can't reintroduce the risk silently. | A scraped-text/news tool without the shared quarantine — injection risk. | | **Externalized `knowledge.py`** | The agent's "brain" is reviewable prose, edited without touching Python. | Inline mega-string — unmaintainable. | | **Confidence via `@field_validator`, not `Field(ge/le)`** | Keeps `minimum`/`maximum` out of the JSON schema (Claude rejects them on ints). | `Field(ge,le)` — schema Claude refuses. | diff --git a/docs/architecture/high-level-design.md b/docs/architecture/high-level-design.md index 822677c..39536b0 100644 --- a/docs/architecture/high-level-design.md +++ b/docs/architecture/high-level-design.md @@ -283,7 +283,7 @@ status. Ratios are not stored and do not trigger IPO-001 evaluation. - **Auth** — one gate at the top of `main()`; nothing renders before it. Retained scan rows and cached AI verdicts remain readable after demotion, while the freshly resolved role/email flows to the Fundamentals action and `RUN_SCAN` is rechecked immediately before agent construction. ([authentication](components/authentication.md), [AI execution boundaries](ai-execution-boundaries.md)) - **Observability** — named structured events and JSON in production are identical across every entrypoint. IPO-002 adds filing lifecycle events; IPO-003 adds document-download completion/failure events; IPO-004 adds successful immutable-submission events containing ids/counts only. ([observability](components/observability.md)) - **Audit trail (OBS-003)** — important user actions (sign-ins, manual scans, the startup data refresh, config changes, CSV exports, admin-page access) are recorded to a durable `audit_logs` table with the actor email, a UTC timestamp, and redacted metadata. IPO category failures add system audit rows with `user_email=NULL`; successful categories remain log-only. Recording is best-effort (never breaks the action) and routes through the same redactor as scan provenance; admins browse it in an in-app viewer. ([audit-log](components/audit-log.md)) -- **Security** — secret redaction on every sink (logs, UI errors, persisted messages); SSRF guards on scraped fetches; CSV-injection escaping; fail-closed AI evidence handling; and four MCP-only Claude agents with SDK built-ins explicitly disabled (`tools=[]`). SEBI listing and document clients use exact HTTPS hosts, public DNS checks where stored URLs become requests, manual bounded redirects, response caps, and hostile-input parsing. ([security](components/security.md), [ipo-screener](components/ipo-screener.md), [AI execution boundaries](ai-execution-boundaries.md)) +- **Security** — secret redaction on every sink (logs, UI errors, persisted messages); SSRF guards on scraped fetches; CSV-injection escaping; fail-closed AI evidence handling; and four MCP-only Claude agents with SDK built-ins explicitly disabled (`tools=[]`) and prompts delivered verbatim, so the CLI never expands `@path` or `/command` text (`verbatim_prompts=True`). SEBI listing and document clients use exact HTTPS hosts, public DNS checks where stored URLs become requests, manual bounded redirects, response caps, and hostile-input parsing. ([security](components/security.md), [ipo-screener](components/ipo-screener.md), [AI execution boundaries](ai-execution-boundaries.md)) - **Persistence, provenance, comparison, and validation** — every shortlisted row carries a deterministic receipt (PROV-002: `triggered_rules` + `indicator_values` + `source`, built by `BaseScanner.build_provenance`); AI screeners add a tamper-evident verdict receipt (PROV-003: model, semantic prompt version, prompt/evidence/context SHA-256, sanitized source URLs — never raw scraped/model text) persisted to the `ai_evaluations` ledger. JOB-003 compares the latest finalized shortlist with the immediately previous finalized shortlist per screener/universe pair. VALID-002 computes per-signal forward returns into `signal_forward_returns` without re-running the screener, VALID-003A/004 aggregate those stored rows into screener/universe/horizon performance metrics and dashboard slices, VALID-003B/004 surface them in a read-only Validation / Signal Performance dashboard, and VALID-004 adds the headless compute job for pending rows. ([scan-service-and-provenance](components/scan-service-and-provenance.md), [storage-persistence](components/storage-persistence.md), [validation](components/validation.md)) - **Caching** — Parquet candle cache (incremental), per-day AI verdict cache (**HMAC-signed and verified before reuse** — a tampered entry is rejected and recomputed), per-session chart cache, 30/60s Streamlit data caches. - **Graceful AI degradation** — cheap gate first; if the SDK/SerpAPI is absent, Technical Analysis falls back to a gate-only BUY while 67 Ka Funda skips the candidate (partial run) — neither crashes the scan. Approved, rejected, **and** error AI decisions are all recorded in `ai_evaluations` for audit. diff --git a/tests/test_agent_terminal_result.py b/tests/test_agent_terminal_result.py index 1f2d9ca..0ad46ea 100644 --- a/tests/test_agent_terminal_result.py +++ b/tests/test_agent_terminal_result.py @@ -86,3 +86,18 @@ def test_usage_limit_classifier_is_shared_by_every_agent(): assert fundamental_agent._mentions_usage_limit("billing refused this request") assert financial_extractor._mentions_usage_limit is mentions_usage_limit assert fundamental_agent._mentions_usage_limit is mentions_usage_limit + + +def test_pinned_sdk_accepts_verbatim_prompts(): + """The installed Agent SDK must understand the option every runner passes. + + Beginner note: + The runner tests swap in fake ``ClaudeAgentOptions`` classes that accept + any keyword, so they cannot notice an SDK pin older than 0.2.158, where + the real options dataclass rejects ``verbatim_prompts`` with + ``TypeError`` and every AI screener would fail at run time. Building the + real options object makes that downgrade fail in CI instead. + """ + from claude_agent_sdk import ClaudeAgentOptions + + assert ClaudeAgentOptions(verbatim_prompts=True).verbatim_prompts is True diff --git a/tests/test_fundamental_agent.py b/tests/test_fundamental_agent.py index bbaffc7..d9c63b8 100644 --- a/tests/test_fundamental_agent.py +++ b/tests/test_fundamental_agent.py @@ -550,7 +550,9 @@ def test_default_sdk_runner_disables_builtin_tools(monkeypatch, tmp_path): ``allowed_tools`` controls permission for named calls, while the Agent SDK's separate ``tools`` option controls which built-in tool families are loaded. Passing an empty list closes the filesystem and shell - surface even if a future SDK default changes. + surface even if a future SDK default changes. ``verbatim_prompts`` + closes the one path that runs before any tool check: the CLI expanding + ``@path`` mentions or dispatching ``/commands`` found in prompt text. """ import sys import types @@ -620,6 +622,7 @@ def create_sdk_mcp_server(*, name: str, version: str, tools: list[object]): ] assert captured["permission_mode"] == "dontAsk" assert captured["setting_sources"] == [] + assert captured["verbatim_prompts"] is True def test_fundamental_agent_normalize_verdict_fills_blank_fields(tmp_path): diff --git a/tests/test_ipo_financial_extractor.py b/tests/test_ipo_financial_extractor.py index adadca9..e23f347 100644 --- a/tests/test_ipo_financial_extractor.py +++ b/tests/test_ipo_financial_extractor.py @@ -1319,7 +1319,14 @@ def create_sdk_mcp_server(*, name: str, version: str, tools: list[object]): def test_default_ipo_sdk_runner_disables_builtin_tools(monkeypatch) -> None: - """Only the three bounded prospectus readers are exposed to extraction.""" + """Only the three bounded prospectus readers are exposed to extraction. + + Beginner note: + The prompt inlines the company name scraped from SEBI listings. + ``verbatim_prompts`` makes the CLI treat that text as plain words, so a + filing named ``@/etc/passwd`` cannot make the CLI read that file into + the model's context before the tool allowlist is ever consulted. + """ captured = _install_ipo_sdk_scenario( monkeypatch, scenario="success", final_text="{}" ) @@ -1346,6 +1353,7 @@ def test_default_ipo_sdk_runner_disables_builtin_tools(monkeypatch) -> None: ] assert captured["permission_mode"] == "dontAsk" assert captured["setting_sources"] == [] + assert captured["verbatim_prompts"] is True @pytest.mark.parametrize( diff --git a/tests/test_sixty_seven_agent.py b/tests/test_sixty_seven_agent.py index 09b474c..3dfd029 100644 --- a/tests/test_sixty_seven_agent.py +++ b/tests/test_sixty_seven_agent.py @@ -830,6 +830,8 @@ def test_default_sdk_runner_disables_builtin_tools(monkeypatch, tmp_path): The SDK has both an allowlist and a built-in-tool selection. An empty built-in list makes the boundary explicit, while retaining the one intended in-process research tool through ``allowed_tools``. + ``verbatim_prompts`` stops the CLI expanding ``@path`` mentions or + dispatching ``/commands`` in prompt text before any tool check runs. """ import sys import types @@ -889,3 +891,4 @@ def create_sdk_mcp_server(*, name: str, version: str, tools: list[object]): assert captured["allowed_tools"] == ["mcp__sixty_seven__research_company"] assert captured["permission_mode"] == "dontAsk" assert captured["setting_sources"] == [] + assert captured["verbatim_prompts"] is True diff --git a/tests/test_technical_analysis_agent.py b/tests/test_technical_analysis_agent.py index 3559105..20f35d1 100644 --- a/tests/test_technical_analysis_agent.py +++ b/tests/test_technical_analysis_agent.py @@ -830,7 +830,9 @@ def test_default_run_registers_only_the_technical_tools(monkeypatch, tmp_path): With permission_mode="dontAsk" the agent can ONLY call tools listed in allowed_tools, so this also confirms the built-in filesystem/bash tools stay - out of reach in a headless run. + out of reach in a headless run. ``verbatim_prompts`` also stops the CLI + expanding ``@path`` mentions or ``/commands`` in prompt text, which happens + before any tool check. """ captured, _ = _install_fake_sdk(monkeypatch) cache = FundamentalsCache(cache_dir=tmp_path) @@ -844,6 +846,7 @@ def test_default_run_registers_only_the_technical_tools(monkeypatch, tmp_path): assert options["tools"] == [] assert options["permission_mode"] == "dontAsk" assert options["setting_sources"] == [] + assert options["verbatim_prompts"] is True # ---------------------------------------------------------------------------