Skip to content

Commit 8629976

Browse files
authored
feat: web domain allowlist and markdown report rendering (#15)
* feat(wire): add ToolExecutionStarted and ToolOutputPart events for live tool feedback Introduces two new wire protocol events so the UI can distinguish the approval/hooks phase from actual tool execution, and stream incremental shell output before the final ToolResult arrives. - ToolExecutionStarted: emitted once per tool call after approval completes, before the tool body runs; file/MCP/external tools emit from the approval path while Shell and RunAgents emit it themselves at the right moment - ToolOutputPart: streamed from the Shell tool as stdout/stderr lines arrive TUI blocks now hold the execution-started spinner until ToolExecutionStarted lands (showing a calm "preparing" row before that), render streamed output as a live tail preview, and the Bash card shows "running" status when partial output is present. The composing _ContentBlock also gains a live Markdown preview while the model writes. * fix(agents): guard against out-of-policy lint findings in explore/plan agents; pad markdown code blocks explore.yaml: require supplemental ruff checks (e.g. --select C901) to be labeled "outside project lint policy" so callers can distinguish enforced violations from advisory findings. plan.yaml: add context-gate rule to verify any lint/complexity finding is in the project's active select list before proposing a refactor — prevents plans driven by rules the project intentionally does not enforce. markdown.py: yield a blank_row() above and below each bordered code block so panels read as distinct sections rather than crowding surrounding prose. Test added to assert the blank-row framing is present. * docs: add agent live tool stream design spec * docs: add agent live tool stream implementation plan * feat(blocks): add subagent output tracking state and methods to _ToolCallBlock Add three new state fields (_subagent_output_parts, _subagent_output_had_stderr, _subagent_execution_started), two new public methods (mark_sub_execution_started, append_sub_output_part), and cleanup in finish_sub_tool_call for the new fields. Also update append_sub_tool_call and append_sub_tool_call_part to recompose after mutations, readying the block for live subagent tool streaming in Task 2. * test: sync default agent-spec snapshot with explore.yaml lint policy The committed explore.yaml carries lint/complexity-policy guidance (do not flag rules such as C901 that are absent from the project's configured rule set) that its inline snapshot in test_agent_spec.py never received, so the test failed on a clean checkout. Regenerate the snapshot to match HEAD. * fix(tui): refine markdown report rendering - Frame fenced code blocks with a blank row above and below so they read as a distinct section instead of crowding surrounding prose. - Add a conservative pre-parse normalizer that repairs malformed GFM tables the model sometimes emits (header glued to prose, a blank line before the |---| delimiter, data rows crammed onto the delimiter line) which markdown-it would otherwise render as raw text. Anchored on the delimiter row, it rebuilds only regions whose header and data cell counts validate, preserves column alignment, and leaves ambiguous or fenced content untouched. * feat(agent): guide output formatting and anchor date awareness - Add an Output Formatting section to the default system prompt: emit well-formed Markdown tables, reserve code fences for actual code (never wrap prose reports, finding lists, or ASCII boxes in a fence), and use status icons sparingly. This stops the model from producing the malformed tables and emoji-laden boxes that rendered poorly in the TUI. - Reword the Date and Time section so the injected ${PYTHINKER_NOW} is framed as the authoritative present, anchoring the agent's sense of 'now', recency, and 'latest' to it instead of a training-era year. - Refresh the prompt snapshot and add coverage for both. * refactor(blocks): align subagent output methods with peer guards, add cap test * feat(web): add fetch/search domain allowlist * test(web): add domain-allowlist fetch tests and tool-description/config docs * fix(typos): unparseable -> unparsable in tasks/todo.md to satisfy spell-check gate * fix: address CodeRabbit review findings on web allowlist and markdown - config.py: reject `allowed_domains` entries that are dots-only (would normalize to empty → silently unrestricted) and any entry containing whitespace (newlines previously slipped past the space/tab check). - tools/web/_allowlist.py: strip trailing dots when normalizing entries so `example.com.` matches `example.com` hosts. - tools/web/search.py + ui/shell/tool_renderers/web.py: emit a structured `returned_results=0` signal on the all-filtered path and have the search renderer prefer it, so an all-filtered result reports "0 results" instead of misreading the prose notice as one result. - ui/shell/components/markdown.py: prefix rebuilt table rows with the captured delimiter-line indent so normalization never promotes an indented table to top level (defensive; the guard already bails on non-empty indent today). - agents/default/system.md: reword the code-fence guidance to use inline code spans for language names (markdownlint MD038). - Tests: reject `.`/whitespace allowlist entries, trailing-dot entry matching, and an all-results-filtered renderer regression; refresh the default-agent system-prompt snapshot.
1 parent 76b90e8 commit 8629976

21 files changed

Lines changed: 748 additions & 21 deletions

docs/en/configuration/config-files.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ api_key = "sk-xxx"
8585
base_url = "https://api.pythinker.com/coding/v1/fetch"
8686
api_key = "sk-xxx"
8787

88+
[web]
89+
allowed_domains = ["example.com", "docs.python.org"]
90+
8891
[mcp.client]
8992
tool_call_timeout_ms = 60000
9093
```
@@ -199,6 +202,16 @@ Configures web fetch service. When enabled, the `FetchURL` tool prioritizes usin
199202
When configuring the Pythinker platform using the `/login` command, search and fetch services are automatically configured.
200203
:::
201204

205+
### `web`
206+
207+
`web` configures policy shared by the `FetchURL` and `SearchWeb` tools.
208+
209+
| Field | Type | Default | Description |
210+
| --- | --- | --- | --- |
211+
| `allowed_domains` | `array<string>` | _unset_ | When set, web fetch and search may only touch these domains and their subdomains. `FetchURL` rejects URLs on other hosts before making any request — including redirect targets, which are re-validated on every hop — and `SearchWeb` drops results from other domains. Unset or empty means unrestricted. Entries must be bare hostnames (e.g. `example.com`), not URLs, paths, or `host:port`. |
212+
213+
This is a coarse governance control layered on top of the existing SSRF protections (which always block private, loopback, link-local, multicast, and reserved addresses); it does not replace them. Matching is label-aware: `example.com` matches `example.com` and `docs.example.com`, but not `notexample.com`.
214+
202215
### `mcp`
203216

204217
`mcp` configures MCP client behavior.

src/pythinker_code/agents/default/system.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ The operating environment is not in a sandbox. Any actions you do will immediate
188188

189189
## Date and Time
190190

191-
The current date and time in ISO format is `${PYTHINKER_NOW}`. This is only a reference for you when searching the web, or checking file modification time, etc. If you need the exact time, use Shell tool with proper command.
191+
The current date and time in ISO format is `${PYTHINKER_NOW}`. Treat this as the authoritative present — it reflects the real "now", which is later than your training data suggests. Anchor all reasoning about the current date, the year, recency, and what counts as the "latest" version or release to `${PYTHINKER_NOW}`; do not fall back on an earlier year you might assume from training. Use it as your reference when searching the web or checking file modification times. If you need the exact time, use the Shell tool with a proper command.
192192

193193
## Working Directory
194194

@@ -261,6 +261,15 @@ Identify the skills that are likely to be useful for the tasks you are currently
261261

262262
Only read skill details when needed to conserve the context window.
263263

264+
# Output Formatting
265+
266+
Your responses are rendered as Markdown in a terminal. Emit well-formed Markdown so it renders cleanly:
267+
268+
- **Tables:** put the header row on its own line, the `|---|---|` delimiter row on the immediately following line (no blank line between them), and one row per line. Never glue a table onto adjacent prose (e.g. `Findings| Col |`) and never cram multiple rows onto one line. Leave a blank line before and after the table.
269+
- Prefer a short bullet list over a table when there are only a few items or any cell is long; reserve tables for genuinely tabular data with short cells.
270+
- **Code fences are for code only.** Use triple-backtick blocks tagged with a language (for example, `python` or `toml`) solely for source, config, or commands — one snippet per block. Never wrap a prose report, finding list, checklist, or ASCII box in a fence to align or frame it; write it as normal Markdown (headings, bullets, tables) so it renders cleanly.
271+
- **Status icons sparingly.** A check/cross/dot can mark a single headline result, but do not prefix every line with one. Use plain words for severity and outcomes (e.g. `High`, `PASS`, `0 findings`). The terminal renders icons as calm monochrome glyphs only outside code fences — another reason not to box reports.
272+
264273
# Ultimate Reminders
265274

266275
At any time, you should be HELPFUL, CONCISE, and ACCURATE. Be thorough in your actions — test what you build, verify what you change — not in your explanations.

src/pythinker_code/config.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
SecretStr,
1515
ValidationError,
1616
field_serializer,
17+
field_validator,
1718
model_validator,
1819
)
1920
from tomlkit.exceptions import TOMLKitError
@@ -217,6 +218,41 @@ class Services(BaseModel):
217218
"""Pythinker AI Fetch configuration."""
218219

219220

221+
class WebConfig(BaseModel):
222+
"""Web fetch/search policy."""
223+
224+
allowed_domains: list[str] | None = Field(
225+
default=None,
226+
description=(
227+
"If set, web fetch and search may only touch these domains and their "
228+
"subdomains. None or empty means unrestricted (default)."
229+
),
230+
)
231+
232+
@field_validator("allowed_domains")
233+
@classmethod
234+
def _validate_allowed_domains(cls, value: list[str] | None) -> list[str] | None:
235+
for entry in value or []:
236+
cleaned = entry.strip()
237+
if not cleaned:
238+
raise ValueError(
239+
"Invalid allowed_domains entry: empty or whitespace-only hostname. "
240+
"Remove it, or omit allowed_domains entirely to leave web access "
241+
"unrestricted."
242+
)
243+
if cleaned.strip(".") == "":
244+
raise ValueError(
245+
f"Invalid allowed_domains entry {entry!r}: hostname must contain "
246+
"domain labels, not only dots."
247+
)
248+
if any(char.isspace() for char in cleaned) or any(char in cleaned for char in "/:"):
249+
raise ValueError(
250+
f"Invalid allowed_domains entry {entry!r}: use a bare hostname "
251+
"like 'example.com', not a URL, path, or host:port."
252+
)
253+
return value
254+
255+
220256
class FeedbackConfig(BaseModel):
221257
"""User-submitted feedback endpoint configuration."""
222258

@@ -364,6 +400,7 @@ class Config(BaseModel):
364400
)
365401
services: Services = Field(default_factory=Services, description="Services configuration")
366402
memory: MemoryConfig = Field(default_factory=MemoryConfig, description="Memory configuration")
403+
web: WebConfig = Field(default_factory=WebConfig, description="Web fetch/search policy")
367404
feedback: FeedbackConfig = Field(
368405
default_factory=FeedbackConfig,
369406
description="User-submitted feedback endpoint configuration",
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Domain allowlist matching shared by the web fetch and search tools."""
2+
3+
from __future__ import annotations
4+
5+
6+
def _normalize(entry: str) -> str:
7+
return entry.strip().strip(".").lower()
8+
9+
10+
def host_in_allowlist(host: str | None, allowed: list[str] | None) -> bool:
11+
"""Return whether *host* is permitted by the *allowed* domain list.
12+
13+
A ``None`` or empty allowlist imposes no restriction (returns ``True``),
14+
preserving unconfigured behavior. Otherwise a host matches when it equals an
15+
allowlist entry or is a subdomain of one. Matching is label-aware and
16+
case-insensitive: ``example.com`` matches ``example.com`` and
17+
``docs.example.com`` but not ``notexample.com``.
18+
"""
19+
entries = [normalized for entry in (allowed or []) if (normalized := _normalize(entry))]
20+
if not entries:
21+
return True
22+
23+
host = (host or "").strip().rstrip(".").lower()
24+
if not host:
25+
return False
26+
27+
return any(host == entry or host.endswith(f".{entry}") for entry in entries)
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Fetch a web page from a URL and extract main text content from it.
1+
Fetch a web page from a URL and extract main text content from it. Requests may be restricted to a configured set of allowed domains; fetching a disallowed host (including via a redirect) returns an error.

src/pythinker_code/tools/web/fetch.py

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from pythinker_code.soul.agent import Runtime
1616
from pythinker_code.soul.toolset import get_current_tool_call_or_none
1717
from pythinker_code.tools.utils import ToolResultBuilder, load_desc
18+
from pythinker_code.tools.web._allowlist import host_in_allowlist
1819
from pythinker_code.utils.aiohttp import new_client_session
1920
from pythinker_code.utils.logging import logger
2021

@@ -23,13 +24,16 @@
2324
_REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
2425

2526

26-
def _validate_fetch_url(url: str) -> str | None:
27+
def _validate_fetch_url(url: str, allowed_domains: list[str] | None = None) -> str | None:
2728
parsed = urlparse(url)
2829
if parsed.scheme not in {"http", "https"}:
2930
return "Only http and https URLs are supported."
3031
if not parsed.hostname:
3132
return "URL must include a host."
3233

34+
if not host_in_allowlist(parsed.hostname, allowed_domains):
35+
return "URL host is not in the configured web allowlist."
36+
3337
try:
3438
infos = socket.getaddrinfo(parsed.hostname, parsed.port, type=socket.SOCK_STREAM)
3539
except socket.gaierror:
@@ -76,23 +80,29 @@ def __init__(self, reason: str) -> None:
7680

7781

7882
async def _get_revalidating_redirects(
79-
session: aiohttp.ClientSession, url: str, headers: dict[str, str]
83+
session: aiohttp.ClientSession,
84+
url: str,
85+
headers: dict[str, str],
86+
allowed_domains: list[str] | None = None,
8087
) -> aiohttp.ClientResponse:
8188
"""GET ``url``, following redirects manually and re-validating every hop.
8289
8390
aiohttp follows redirects internally without re-checking the destination, so
8491
a public URL that 30x-redirects to a private/link-local address (e.g. a cloud
8592
metadata endpoint at 169.254.169.254) would otherwise sail past
8693
``_validate_fetch_url``. We disable automatic redirects and validate each
87-
``Location`` before following it.
94+
``Location`` against both the SSRF guard and the configured domain allowlist
95+
before following it.
8896
8997
Returns the final, open, non-redirect response (the caller owns closing it).
9098
Raises ``_FetchBlocked`` if any hop is blocked or the redirect limit is hit.
9199
"""
92100
current = url
93-
for _ in range(MAX_FETCH_REDIRECTS + 1):
94-
if reason := _validate_fetch_url(current):
95-
raise _FetchBlocked(reason)
101+
for hop in range(MAX_FETCH_REDIRECTS + 1):
102+
if reason := _validate_fetch_url(current, allowed_domains):
103+
if hop == 0:
104+
raise _FetchBlocked(reason)
105+
raise _FetchBlocked(f"redirect to a disallowed location: {reason}")
96106
response = await session.get(current, headers=headers, allow_redirects=False)
97107
location = response.headers.get(aiohttp.hdrs.LOCATION)
98108
if response.status in _REDIRECT_STATUSES and location:
@@ -116,6 +126,7 @@ def __init__(self, config: Config, runtime: Runtime):
116126
super().__init__()
117127
self._runtime = runtime
118128
self._service_config = config.services.pythinker_ai_fetch
129+
self._allowed_domains = config.web.allowed_domains
119130

120131
@override
121132
async def __call__(self, params: Params) -> ToolReturnValue:
@@ -134,11 +145,18 @@ async def __call__(self, params: Params) -> ToolReturnValue:
134145
return ret
135146
logger.warning("Failed to fetch URL via service: {error}", error=ret.message)
136147
# fallback to local fetch if service fetch fails
137-
return await self.fetch_with_http_get(params)
148+
return await self.fetch_with_http_get(params, self._allowed_domains)
138149

139150
@staticmethod
140-
async def fetch_with_http_get(params: Params) -> ToolReturnValue:
151+
async def fetch_with_http_get(
152+
params: Params, allowed_domains: list[str] | None = None
153+
) -> ToolReturnValue:
141154
builder = ToolResultBuilder(max_line_length=None)
155+
# Validate the initial URL up front so a disallowed host is rejected
156+
# before any network session is opened; the redirect helper below
157+
# re-validates every subsequent hop.
158+
if reason := _validate_fetch_url(params.url, allowed_domains):
159+
return builder.error(f"Failed to fetch URL: {reason}", brief="URL blocked")
142160
headers = {
143161
"User-Agent": (
144162
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
@@ -150,7 +168,9 @@ async def fetch_with_http_get(params: Params) -> ToolReturnValue:
150168
fetch_timeout = aiohttp.ClientTimeout(total=180, sock_read=60, sock_connect=15)
151169
async with new_client_session(timeout=fetch_timeout) as session:
152170
try:
153-
response = await _get_revalidating_redirects(session, params.url, headers)
171+
response = await _get_revalidating_redirects(
172+
session, params.url, headers, allowed_domains
173+
)
154174
except _FetchBlocked as blocked:
155175
return builder.error(
156176
f"Failed to fetch URL: {blocked.reason}", brief="URL blocked"
@@ -245,7 +265,7 @@ async def _fetch_with_service(self, params: Params) -> ToolReturnValue:
245265
"Fetch service is not configured. You may want to try other methods to fetch.",
246266
brief="Fetch service not configured",
247267
)
248-
if reason := _validate_fetch_url(params.url):
268+
if reason := _validate_fetch_url(params.url, self._allowed_domains):
249269
return builder.error(f"Failed to fetch URL: {reason}", brief="URL blocked")
250270

251271
headers = {
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
WebSearch tool allows you to search on the internet to get latest information, including news, documents, release notes, blog posts, papers, etc.
1+
WebSearch tool allows you to search on the internet to get latest information, including news, documents, release notes, blog posts, papers, etc. Results may be limited to a configured set of allowed domains.

src/pythinker_code/tools/web/search.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from pathlib import Path
22
from typing import override
3+
from urllib.parse import urlparse
34

45
import aiohttp
56
from pydantic import BaseModel, Field, ValidationError
@@ -12,6 +13,7 @@
1213
from pythinker_code.soul.toolset import get_current_tool_call_or_none
1314
from pythinker_code.tools import SkipThisTool
1415
from pythinker_code.tools.utils import ToolResultBuilder, load_desc
16+
from pythinker_code.tools.web._allowlist import host_in_allowlist
1517
from pythinker_code.utils.aiohttp import new_client_session
1618
from pythinker_code.utils.logging import logger
1719

@@ -53,6 +55,7 @@ def __init__(self, config: Config, runtime: Runtime):
5355
self._api_key = config.services.pythinker_ai_search.api_key
5456
self._oauth_ref = config.services.pythinker_ai_search.oauth
5557
self._custom_headers = config.services.pythinker_ai_search.custom_headers or {}
58+
self._allowed_domains = config.web.allowed_domains
5659

5760
@override
5861
async def __call__(self, params: Params) -> ToolReturnValue:
@@ -145,6 +148,26 @@ async def __call__(self, params: Params) -> ToolReturnValue:
145148
brief="Search request failed",
146149
)
147150

151+
if self._allowed_domains:
152+
kept = [
153+
result
154+
for result in results
155+
if host_in_allowlist(urlparse(result.url).hostname, self._allowed_domains)
156+
]
157+
dropped = len(results) - len(kept)
158+
results = kept
159+
if dropped:
160+
builder.extras(allowlist_filtered=dropped)
161+
if not results:
162+
# Structured zero-result signal so the renderer reports "0
163+
# results" instead of misreading the prose below as one result.
164+
builder.extras(returned_results=0)
165+
return builder.ok(
166+
f"All {dropped} search result(s) were outside the configured "
167+
"web allowlist and have been omitted.",
168+
brief="Filtered by allowlist",
169+
)
170+
148171
for i, result in enumerate(results):
149172
if i > 0:
150173
builder.write("---\n\n")

0 commit comments

Comments
 (0)