Skip to content

feat: web domain allowlist and markdown report rendering - #15

Merged
elkaix merged 15 commits into
mainfrom
feat/web-allowlist-and-markdown-rendering
May 29, 2026
Merged

feat: web domain allowlist and markdown report rendering#15
elkaix merged 15 commits into
mainfrom
feat/web-allowlist-and-markdown-rendering

Conversation

@elkaix

@elkaix elkaix commented May 29, 2026

Copy link
Copy Markdown
Member

Summary

Unifies two previously-local feature branches onto current main in a single PR:

  • Web domain allowlist (feat/web-allowed-domains) — adds a configurable web.allowed_domains policy enforced by FetchURL and SearchWeb. A new tools/web/_allowlist.py provides label-aware, case-insensitive subdomain matching; an unconfigured (empty/None) allowlist imposes no restriction. Fetch validates the initial URL before opening any session and re-validates every redirect hop against both the allowlist and the existing SSRF guard. Search filters results to allowed hosts.
  • Markdown report rendering (fix/markdown-rendering-and-date-awareness) — improves TUI rendering of model-generated reports: normalizes glued/crammed GFM tables, leaves inline pipes and fenced code untouched, and renders priority-matrix code blocks as grouped rows.

Merge notes

Both branches were far behind main; conflicts were resolved by integrating, not overwriting:

  • tools/web/fetch.py: combined the allowlist check and the SSRF IP check into the single _validate_fetch_url, and threaded allowed_domains through the SSRF-safe redirect helper so each hop is checked for both.
  • ui/shell/components/markdown.py: kept both table-fixing passes — _repair_crammed_markdown_tables (heading-glued-to-header) then _normalize_markdown_tables (general glued-table rebuild).
  • Subagent live-tool-stream code/tests already finalized in main were kept; the older stubbed versions from the feature branch were dropped.
  • Updated the pyinstaller hiddenimports snapshot for the new _allowlist module.

Verification

  • ruff check + ruff format --check: clean
  • pyright: 0 errors
  • Unit suite: full tests/ green (incl. web allowlist, markdown rendering, pyinstaller)
  • e2e: tests_e2e/ green

Summary by CodeRabbit

  • New Features

    • Web domain allowlist: new top-level web config (allowed_domains) to restrict Fetch and Search; redirects revalidated per hop; disallowed hosts cause fetch errors and search results are filtered.
  • Improvements

    • Search/UI now surfaces how many results were filtered to the allowlist and shows "Found 0 results" when all are omitted.
    • Markdown rendering: automatic table-repair while preserving fenced code blocks.
  • Tests & Docs

    • Added/expanded tests and docs for allowlist, redirects, and markdown behavior.

Review Change Stack

elkaix added 13 commits May 26, 2026 22:59
…ve 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.
…n 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.
…CallBlock

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.
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.
- 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.
- 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.
# Conflicts:
#	src/pythinker_code/config.py
#	src/pythinker_code/tools/web/fetch.py
#	tests/core/test_config.py
…into unified branch

# Conflicts:
#	docs/superpowers/plans/2026-05-26-agent-live-tool-stream.md
#	src/pythinker_code/ui/shell/components/markdown.py
#	src/pythinker_code/ui/shell/visualize/_blocks.py
#	src/pythinker_code/ui/shell/visualize/_live_view.py
#	tests/ui/test_shell_markdown.py
#	tests/ui_and_conv/test_tool_call_block.py
@coderabbitai

coderabbitai Bot commented May 29, 2026

Copy link
Copy Markdown
Contributor
📝 Walkthrough

Walkthrough

Adds a top-level web.allowed_domains config, implements normalized host allowlist matching, enforces the allowlist in FetchURL (initial URL + each redirect) and SearchWeb (post-parse filtering + extras), surfaces filtered-count in the TUI, adds fence-aware Markdown table normalization, and updates prompts, docs, and tests.

Changes

Web Domain Allowlist Feature

Layer / File(s) Summary
Web Config and allowlist helper
src/pythinker_code/config.py, src/pythinker_code/tools/web/_allowlist.py, tests/tools/test_web_allowlist.py, tests/core/test_config.py, tests/utils/test_pyinstaller_utils.py
Add WebConfig.allowed_domains with validation for bare hostnames; implement _normalize() and host_in_allowlist() for case-insensitive + subdomain matching; update default config snapshot and pyinstaller hidden-imports.
FetchURL URL and Redirect Allowlist Validation
src/pythinker_code/tools/web/fetch.py, src/pythinker_code/tools/web/fetch.md, tests/tools/test_fetch_url.py, tests/tools/test_web_allowlist_tools.py, tests/tools/test_tool_descriptions.py
Thread config.web.allowed_domains into FetchURL; _validate_fetch_url() and _get_revalidating_redirects() accept/enforce allowed_domains, block disallowed initial URLs and redirect targets with distinct messages; add redirect tests and update fetch docs and descriptions.
SearchWeb Result Filtering and UI Rendering
src/pythinker_code/tools/web/search.py, src/pythinker_code/tools/web/search.md, src/pythinker_code/ui/shell/tool_renderers/web.py, tests/tools/test_web_allowlist_tools.py, tests/ui_and_conv/test_tui_card_tool_renderers.py
SearchWeb stores config.web.allowed_domains, filters parsed results by hostname, records dropped count in extras["allowlist_filtered"]; UI renderer reads that extra and appends a "filtered to allowlist" indicator when nonzero; integration test verifies behavior.

Markdown Table Normalization

Layer / File(s) Summary
Table Repair and Normalization Logic
src/pythinker_code/ui/shell/components/markdown.py, tests/ui/test_shell_markdown.py
Add regex helpers to detect GFM pipe-table delimiter runs and headers, implement fence-aware _normalize_markdown_tables() that repairs only well-formed tables outside fenced blocks, and add three UI tests for glued headers, inline pipes, and fenced code protection.

System Prompt and Documentation Updates

Layer / File(s) Summary
Agent System Prompt Guidance
src/pythinker_code/agents/default/system.md, tests/core/test_default_agent.py, tests/core/test_load_agent.py
${PYTHINKER_NOW} marked authoritative for time-sensitive reasoning; new "Output Formatting" section prescribes Markdown table formatting and code-fence usage; tests added/updated to assert these prompt contents.
Configuration and Tool Documentation
docs/en/configuration/config-files.md, src/pythinker_code/tools/web/fetch.md, src/pythinker_code/tools/web/search.md, tasks/todo.md
Add [web] example and ### web docs describing allowed_domains behavior, redirect re-validation, and search result filtering; add TODO entry summarizing work and follow-ups.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

enhancement

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 37.31% which is insufficient. The required threshold is 70.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed PR title follows conventional commits format (feat: subject) and accurately summarizes the two main changes: web domain allowlist and markdown report rendering improvements.
Description check ✅ Passed PR description is comprehensive, covering both features, merge mechanics, verification status, and includes detailed notes on integration decisions and known follow-ups.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/web-allowlist-and-markdown-rendering

Comment @coderabbitai help to get the list of available commands and usage tips.

@elkaix

elkaix commented May 29, 2026

Copy link
Copy Markdown
Member Author

Heads-up for review: the domain allowlist is enforced on the local-HTTP fetch path (fetch_with_http_get) and on SearchWeb results, but the _fetch_with_service path (services.pythinker_ai_fetch) returns before fetch_with_http_get and therefore does not apply web.allowed_domains. This is inherited from the original feat/web-allowed-domains design (not introduced by the merge) — flagging it as a potential follow-up so a service-backed fetch to a disallowed host is also blocked.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/tools/test_fetch_url.py (1)

20-28: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid module-wide mocking of private validator internals.

This autouse patch couples tests to _validate_fetch_url internals and can mask real behavior regressions. Prefer exercising observable behavior via tool inputs/config and targeted local test servers.

As per coding guidelines, tests/**/*.py: Flag tests that mock internal implementation details rather than observable behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/tools/test_fetch_url.py` around lines 20 - 28, The autouse fixture
_bypass_ssrf_validation is patching the private function
fetch_module._validate_fetch_url which couples tests to internals; replace it by
removing the module-wide monkeypatch and instead make tests exercise observable
behavior: start a local test server or use configured allowed-hosts/allowlist
inputs and call the public fetch API, or if a bypass is absolutely needed create
a non-autouse, test-scoped fixture that overrides only the public
configuration/parameter that controls SSRF validation (e.g., pass an explicit
allowlist or use a public setter) so you no longer monkeypatch
fetch_module._validate_fetch_url directly; update tests to use that fixture or
server accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/pythinker_code/agents/default/system.md`:
- Line 270: The inline code span in the sentence beginning "Code fences are for
code only." contains spaced triple-backticks (e.g. "python, ```toml") which
triggers MD038; fix it by rewriting that fragment to use proper inline code
spans for language names (e.g. `python` and `toml`) and remove any embedded
backticks or spaces inside a single code span so the sentence reads like: Use
triple-backtick blocks tagged with a language (for example, `python` or `toml`)
solely for source, config, or commands.

In `@src/pythinker_code/config.py`:
- Around line 235-247: The current allowed_domains loop accepts entries like "."
and misses other whitespace; update the validation in the loop that processes
each entry (the cleaned variable used in that loop) to: after cleaned =
entry.strip() also reject entries that are empty or consist only of dots (e.g.,
cleaned == "" or all(ch == "." for ch in cleaned)) and reject any entry
containing any whitespace character using char.isspace() rather than only
checking " " and "\t"; keep the existing URL/port/path checks (e.g., characters
'/' or ':') but replace the whitespace check with a char.isspace() check and
update the ValueError messages accordingly so entries like "." or newline-only
strings are refused.

In `@src/pythinker_code/tools/web/_allowlist.py`:
- Around line 6-7: The _normalize function currently only removes leading dots
and whitespace which causes entries like "example.com." not to match
"example.com"; update _normalize(entry: str) to trim whitespace and strip both
leading and trailing dots (e.g., use entry.strip().strip(".").lower() or
equivalent) so that entries with trailing dots are normalized the same as
without them; keep the lowercasing behavior and reference the _normalize
function when making the change.

In `@src/pythinker_code/tools/web/search.py`:
- Around line 162-166: The all-filtered branch currently returns only prose via
builder.ok, which downstream rendering can miscount; update the return to
include a structured zero-result signal (e.g. add an extras or metadata field
like extras.returned_results = 0 or returned_results=0) alongside the existing
message and brief so renderers can prefer the explicit count; locate the branch
that calls builder.ok (the block returning "All {dropped} search result(s) ...")
and augment that builder.ok call to include the extras/returned_results field
set to 0.

In `@src/pythinker_code/ui/shell/components/markdown.py`:
- Around line 406-468: The code strips the original leading indentation
(captured in line_prefix) when re-emitting the normalized GFM table, which moves
indented tables to top-level; update the emission so every emitted table line
(header, marker row, and each data row) is prefixed with line_prefix (i.e.,
replace the out += "| " + ... joins with out += line_prefix + "| " + ...), and
ensure the blank-line insertion respects the same indentation by inserting
line_prefix + "\n" or line_prefix + "\n\n" as appropriate so the reconstructed
table preserves its original left padding; look for the variables line_prefix,
header_cells, markers, data_rows and the places that append header/marker/row
strings to out.

In `@tasks/todo.md`:
- Line 506: Update the typo in the tasks/todo.md entry: change the word
"unparseable" to "unparsable" in the string "fail-closed on unparseable hosts;
allowlist-before-DNS ordering." so the typo checker passes; locate and edit that
exact phrase in the file and commit the corrected spelling.

In `@tests/tools/test_web_allowlist_tools.py`:
- Line 13: The test is reaching into internal implementation by calling
_validate_fetch_url and patching new_client_session; instead, change the test to
exercise the public FetchURL behavior: configure a controlled allowlist/denylist
via the same config used by FetchURL, run a small local test HTTP server (or a
fixture) and assert FetchURL.fetch (or the public method used) accepts or
rejects URLs as expected; remove direct calls to _validate_fetch_url and the
new_client_session patch so the test validates observable behavior rather than
internal implementation.

In `@tests/ui_and_conv/test_tui_card_tool_renderers.py`:
- Around line 911-929: Add a regression test function in
tests/ui_and_conv/test_tui_card_tool_renderers.py that covers the "all results
filtered" payload branch: reuse the _render helper (as in
test_search_shows_allowlist_filtered_indicator) but pass an empty output and
details with extras indicating allowlist_filtered count and an
all-results-filtered flag (e.g., extras={"allowlist_filtered": X,
"all_results_filtered": True}); assert the rendered output shows zero results
(e.g., contains "Found 0 result" or "Found 0 results") and includes the UI
indicator/message for all-results filtered (e.g., contains "filtered to
allowlist" or an "all results filtered" phrasing) to lock in correct zero-result
rendering.

---

Outside diff comments:
In `@tests/tools/test_fetch_url.py`:
- Around line 20-28: The autouse fixture _bypass_ssrf_validation is patching the
private function fetch_module._validate_fetch_url which couples tests to
internals; replace it by removing the module-wide monkeypatch and instead make
tests exercise observable behavior: start a local test server or use configured
allowed-hosts/allowlist inputs and call the public fetch API, or if a bypass is
absolutely needed create a non-autouse, test-scoped fixture that overrides only
the public configuration/parameter that controls SSRF validation (e.g., pass an
explicit allowlist or use a public setter) so you no longer monkeypatch
fetch_module._validate_fetch_url directly; update tests to use that fixture or
server accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1e76bf5c-4cb9-45f6-ab92-809dce188d60

📥 Commits

Reviewing files that changed from the base of the PR and between 76b90e8 and 3d5835b.

📒 Files selected for processing (21)
  • docs/en/configuration/config-files.md
  • src/pythinker_code/agents/default/system.md
  • src/pythinker_code/config.py
  • src/pythinker_code/tools/web/_allowlist.py
  • src/pythinker_code/tools/web/fetch.md
  • src/pythinker_code/tools/web/fetch.py
  • src/pythinker_code/tools/web/search.md
  • src/pythinker_code/tools/web/search.py
  • src/pythinker_code/ui/shell/components/markdown.py
  • src/pythinker_code/ui/shell/tool_renderers/web.py
  • tasks/todo.md
  • tests/core/test_config.py
  • tests/core/test_default_agent.py
  • tests/core/test_load_agent.py
  • tests/tools/test_fetch_url.py
  • tests/tools/test_tool_descriptions.py
  • tests/tools/test_web_allowlist.py
  • tests/tools/test_web_allowlist_tools.py
  • tests/ui/test_shell_markdown.py
  • tests/ui_and_conv/test_tui_card_tool_renderers.py
  • tests/utils/test_pyinstaller_utils.py

Comment thread src/pythinker_code/agents/default/system.md Outdated
Comment thread src/pythinker_code/config.py
Comment thread src/pythinker_code/tools/web/_allowlist.py Outdated
Comment thread src/pythinker_code/tools/web/search.py
Comment thread src/pythinker_code/ui/shell/components/markdown.py
Comment thread tasks/todo.md Outdated
Comment thread tests/tools/test_web_allowlist_tools.py
Comment thread tests/ui_and_conv/test_tui_card_tool_renderers.py
- 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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/pythinker_code/tools/web/search.py (1)

159-169: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Guard the “filtered by allowlist” return with dropped > 0.

This branch also runs when the backend returns zero results (so dropped == 0), which produces a misleading “outside allowlist” message.

Proposed fix
-            if not results:
+            if dropped > 0 and not results:
                 # Structured zero-result signal so the renderer reports "0
                 # results" instead of misreading the prose below as one result.
                 builder.extras(returned_results=0)
                 return builder.ok(
                     f"All {dropped} search result(s) were outside the configured "
                     "web allowlist and have been omitted.",
                     brief="Filtered by allowlist",
                 )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/pythinker_code/tools/web/search.py` around lines 159 - 169, The current
branch returns a "Filtered by allowlist" message whenever results is empty, even
if no items were dropped (dropped == 0); update the conditional so the
allowlist-filtered return only happens when dropped > 0 and results is empty.
Locate the block using the variables dropped and results and the calls
builder.extras(...) and builder.ok(...), and change the logic to first set
builder.extras(returned_results=0) when not results and then, if dropped > 0,
call builder.extras(allowlist_filtered=dropped) and return the builder.ok(...)
filtered-by-allowlist message; otherwise return the generic zero-results
response.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/pythinker_code/tools/web/search.py`:
- Around line 159-169: The current branch returns a "Filtered by allowlist"
message whenever results is empty, even if no items were dropped (dropped == 0);
update the conditional so the allowlist-filtered return only happens when
dropped > 0 and results is empty. Locate the block using the variables dropped
and results and the calls builder.extras(...) and builder.ok(...), and change
the logic to first set builder.extras(returned_results=0) when not results and
then, if dropped > 0, call builder.extras(allowlist_filtered=dropped) and return
the builder.ok(...) filtered-by-allowlist message; otherwise return the generic
zero-results response.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 0753b562-b993-4438-9f1a-94ef3fbe7973

📥 Commits

Reviewing files that changed from the base of the PR and between f348913 and c6d3bc0.

📒 Files selected for processing (9)
  • src/pythinker_code/agents/default/system.md
  • src/pythinker_code/config.py
  • src/pythinker_code/tools/web/_allowlist.py
  • src/pythinker_code/tools/web/search.py
  • src/pythinker_code/ui/shell/components/markdown.py
  • src/pythinker_code/ui/shell/tool_renderers/web.py
  • tests/core/test_default_agent.py
  • tests/tools/test_web_allowlist.py
  • tests/ui_and_conv/test_tui_card_tool_renderers.py

@elkaix
elkaix merged commit 8629976 into main May 29, 2026
29 checks passed
@elkaix
elkaix deleted the feat/web-allowlist-and-markdown-rendering branch May 29, 2026 18:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant