Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/fundamentals/fundamental_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions backend/ipo/agents/financial_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions backend/sixty_seven/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 6 additions & 0 deletions backend/technical/technical_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
66 changes: 61 additions & 5 deletions docs/architecture/ai-execution-boundaries.md
Original file line number Diff line number Diff line change
@@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/components/fundamentals-ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/components/ipo-extraction-ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
4 changes: 3 additions & 1 deletion docs/architecture/components/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/components/sixty-seven-ka-funda-ai.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading