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
39 changes: 39 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
url = "2.5"
rand = "0.9"
readability-js = "0.1.5"
regex = "1"
rmcp = { version = "1.4", features = [
"client",
Expand Down
1 change: 1 addition & 0 deletions deny.toml
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ allow = [
"0BSD",
"CDLA-Permissive-2.0",
"LGPL-2.1-or-later",
"UPL-1.0",
]
# The confidence threshold for detecting a license from license text.
# The higher the value, the more closely the license text must be to the
Expand Down
6 changes: 4 additions & 2 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,8 @@ Pulse(注意活性化)のスケジューラ設定。
|---|---|---|---|---|
| `web_fetch.allowed_schemes` | `[string]` | 任意 | `["https"]` | 許可する URL scheme |
| `web_fetch.timeout_secs` | `u64` | 任意 | `15` | リクエストタイムアウト秒 |
| `web_fetch.max_bytes` | `usize` | 任意 | `65536` | 最大レスポンスボディサイズ(バイト) |
| `web_fetch.max_fetch_bytes` | `usize` | 任意 | `524288` | 最大フェッチサイズ(バイト)。この上限までストリーム読み込み、超過時は取得済み部分を返す |
| `web_fetch.max_output_bytes` | `usize` | 任意 | `65536` | 本文の最大バイト数。HTML処理後の本文をこの上限で切り詰める(warning は上限外) |
| `web_fetch.allow_private_ips` | `bool` | 任意 | `false` | プライベート/ループバック IP へのアクセスを許可 |
| `web_fetch.denylist` | `[string]` | 任意 | `[]` | ブロックするホストのリスト(サブドメインワイルドカード `*.prefix` 対応) |
| `web_fetch.allowlist` | `[string]` | 任意 | `[]` | 許可するホストのリスト(空の場合全許可) |
Expand Down Expand Up @@ -321,7 +322,8 @@ web_fetch:
allowed_schemes:
- https
timeout_secs: 15
max_bytes: 65536
max_fetch_bytes: 524288
max_output_bytes: 65536
allow_private_ips: false
denylist: []
allowlist: []
Expand Down
16 changes: 12 additions & 4 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -388,21 +388,30 @@ Environment variables:
- 入力:
- `url: string` 必須
- `timeout_secs: integer` 任意。既定値は設定ファイル参照
- `max_bytes: integer` 任意。既定値は設定ファイル参照(デフォルト 64KB)
- `max_output_bytes: integer` 任意。本文の最大バイト数(warning は上限外、デフォルト 64KB)
- 挙動:
- URL scheme 検証(デフォルト HTTPS のみ許可)
- Host denylist/allowlist チェック
- SSRF 対策: プライベート IP / ループバックアドレスへのアクセスをブロック(`allow_private_ips: true` で解除可)
- DNS 解決後の SSRF 再検証
- HTTP リダイレクトは手動追跡(各ホップで SSRF 再検証、最大リダイレクト数制限あり)
- HTML は `htmd` クレートで Markdown に変換、`text/plain` はそのまま返す
- HTML は Mozilla Readability.js ベースの本文抽出(`readability-js` クレート)を行い、抽出した clean HTML を Markdown に変換。Readability 失敗時は `htmd` にフォールバック
- `text/plain` はそのまま返す
- コンテンツバリデーション: プロンプトインジェクション検出パターンでスキャン
- `max_bytes` でレスポンスサイズの上限(超過時は即座にエラー)
- `max_fetch_bytes` でフェッチサイズの上限。超過時はエラーにせず取得済み部分を返す(partial content)。`max_output_bytes` で最終出力サイズを制限
- 末尾に untrusted content warning を付与
- 本文抽出方式に応じて `extraction` details フィールドに `readability-js`, `fallback-html-to-markdown`, `verbatim` を返す
- 読み取り専用ツール (`is_read_only: true`)
- `details`:
- `final_url` (リダイレクト後の最終URL)
- `content_type`
- `content_length` (Content-Length ヘッダの値、無い場合は `null`)
- `fetched_bytes` (実際に取得したバイト数)
- `response_truncated` (fetch上限で打ち切ったか)
- `output_truncated` (出力上限で切ったか)
- `max_fetch_bytes`
- `max_output_bytes`
- `extraction` (本文抽出方式)
- 主な失敗:
- `url must not be empty`
- `scheme '...' is not allowed`
Expand All @@ -415,7 +424,6 @@ Environment variables:
- `redirect without Location header`
- `content blocked: ...`
- `response body is not valid UTF-8`
- `response too large: exceeds max_bytes N`

実装: [egopulse/src/tools/web_fetch/mod.rs](../../egopulse/src/tools/web_fetch/mod.rs)

Expand Down
6 changes: 4 additions & 2 deletions src/config/loader.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ struct FilePulseConfig {
struct FileWebFetchConfig {
allowed_schemes: Option<Vec<String>>,
timeout_secs: Option<u64>,
max_bytes: Option<usize>,
max_fetch_bytes: Option<usize>,
max_output_bytes: Option<usize>,
allow_private_ips: Option<bool>,
denylist: Option<Vec<String>>,
allowlist: Option<Vec<String>>,
Expand Down Expand Up @@ -998,7 +999,8 @@ fn normalize_web_fetch(file: Option<FileWebFetchConfig>) -> WebFetchConfig {
.allowed_schemes
.unwrap_or_else(|| vec!["https".to_string()]),
timeout_secs: fw.timeout_secs.unwrap_or(15),
max_bytes: fw.max_bytes.unwrap_or(64 * 1024),
max_fetch_bytes: fw.max_fetch_bytes.unwrap_or(512 * 1024),
max_output_bytes: fw.max_output_bytes.unwrap_or(64 * 1024),
allow_private_ips: fw.allow_private_ips.unwrap_or(false),
denylist: fw.denylist.unwrap_or_default(),
allowlist: fw.allowlist.unwrap_or_default(),
Expand Down
14 changes: 11 additions & 3 deletions src/config/persist.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,10 @@ struct SerializableWebFetchConfig {
allowed_schemes: Vec<String>,
#[serde(skip_serializing_if = "is_default_u64")]
timeout_secs: u64,
#[serde(skip_serializing_if = "is_default_usize_512k")]
max_fetch_bytes: usize,
#[serde(skip_serializing_if = "is_default_usize_64k")]
max_bytes: usize,
max_output_bytes: usize,
#[serde(skip_serializing_if = "std::ops::Not::not")]
allow_private_ips: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
Expand All @@ -188,6 +190,10 @@ fn is_default_u64(v: &u64) -> bool {
*v == 0 || *v == 15
}

fn is_default_usize_512k(v: &usize) -> bool {
*v == 0 || *v == 512 * 1024
}

fn is_default_usize_64k(v: &usize) -> bool {
*v == 0 || *v == 64 * 1024
}
Expand Down Expand Up @@ -362,7 +368,8 @@ impl From<&Config> for SerializableConfig {
let wf_defaults = super::web_fetch::WebFetchConfig::default();
let is_default = wf.allowed_schemes == wf_defaults.allowed_schemes
&& wf.timeout_secs == wf_defaults.timeout_secs
&& wf.max_bytes == wf_defaults.max_bytes
&& wf.max_fetch_bytes == wf_defaults.max_fetch_bytes
&& wf.max_output_bytes == wf_defaults.max_output_bytes
&& !wf.allow_private_ips
&& wf.denylist.is_empty()
&& wf.allowlist.is_empty()
Expand All @@ -373,7 +380,8 @@ impl From<&Config> for SerializableConfig {
Some(SerializableWebFetchConfig {
allowed_schemes: wf.allowed_schemes.clone(),
timeout_secs: wf.timeout_secs,
max_bytes: wf.max_bytes,
max_fetch_bytes: wf.max_fetch_bytes,
max_output_bytes: wf.max_output_bytes,
allow_private_ips: wf.allow_private_ips,
denylist: wf.denylist.clone(),
allowlist: wf.allowlist.clone(),
Expand Down
95 changes: 81 additions & 14 deletions src/config/web_fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@

const DEFAULT_ALLOWED_SCHEMES: &[&str] = &["https"];
const DEFAULT_TIMEOUT_SECS: u64 = 15;
const DEFAULT_MAX_BYTES: usize = 64 * 1024;
const DEFAULT_MAX_FETCH_BYTES: usize = 512 * 1024;
const DEFAULT_MAX_OUTPUT_BYTES: usize = 64 * 1024;
const DEFAULT_MAX_SCAN_BYTES: usize = 64 * 1024;

// ---------------------------------------------------------------------------
Expand All @@ -21,8 +22,10 @@ pub(crate) struct WebFetchConfig {
pub allowed_schemes: Vec<String>,
/// Request timeout in seconds. Default: 15
pub timeout_secs: u64,
/// Maximum response body size in bytes. Default: 65536
pub max_bytes: usize,
/// Maximum bytes to fetch from the network. Default: 524288 (512KB)
pub max_fetch_bytes: usize,
/// Maximum bytes in the final output after processing. Default: 65536 (64KB)
pub max_output_bytes: usize,
/// Whether to allow requests to private/loopback IPs. Default: false
pub allow_private_ips: bool,
/// Host denylist (exact match + subdomain wildcard). Default: empty
Expand Down Expand Up @@ -57,7 +60,8 @@ impl Default for WebFetchConfig {
.map(|s| (*s).to_string())
.collect(),
timeout_secs: DEFAULT_TIMEOUT_SECS,
max_bytes: DEFAULT_MAX_BYTES,
max_fetch_bytes: DEFAULT_MAX_FETCH_BYTES,
max_output_bytes: DEFAULT_MAX_OUTPUT_BYTES,
allow_private_ips: false,
denylist: Vec::new(),
allowlist: Vec::new(),
Expand Down Expand Up @@ -86,6 +90,8 @@ impl WebFetchConfig {
/// * Fills `allowed_schemes` with `["https"]` when empty.
/// * Lowercases / trims hosts in denylist/allowlist (handles `*.prefix`).
/// * Falls back to defaults for zero-valued numeric fields.
/// * Ensures `content_validation.max_scan_bytes >= max_output_bytes` so that
/// prompt-injection scanning covers the entire output body.
pub(crate) fn normalize(mut self) -> Self {
if self.allowed_schemes.is_empty() {
self.allowed_schemes = DEFAULT_ALLOWED_SCHEMES
Expand All @@ -97,12 +103,18 @@ impl WebFetchConfig {
self.denylist = normalize_hosts(self.denylist);
self.allowlist = normalize_hosts(self.allowlist);

if self.max_bytes == 0 {
self.max_bytes = DEFAULT_MAX_BYTES;
if self.max_fetch_bytes == 0 {
self.max_fetch_bytes = DEFAULT_MAX_FETCH_BYTES;
}
if self.max_output_bytes == 0 {
self.max_output_bytes = DEFAULT_MAX_OUTPUT_BYTES;
}
if self.timeout_secs == 0 {
self.timeout_secs = DEFAULT_TIMEOUT_SECS;
}
if self.content_validation.max_scan_bytes < self.max_output_bytes {
self.content_validation.max_scan_bytes = self.max_output_bytes;
}

self
}
Expand Down Expand Up @@ -137,7 +149,8 @@ mod tests {

assert_eq!(cfg.allowed_schemes, vec!["https"]);
assert_eq!(cfg.timeout_secs, 15);
assert_eq!(cfg.max_bytes, 64 * 1024);
assert_eq!(cfg.max_fetch_bytes, 512 * 1024);
assert_eq!(cfg.max_output_bytes, 64 * 1024);
assert!(!cfg.allow_private_ips);
assert!(cfg.denylist.is_empty());
assert!(cfg.allowlist.is_empty());
Expand All @@ -153,7 +166,8 @@ allowed_schemes:
- https
- http
timeout_secs: 30
max_bytes: 50000
max_fetch_bytes: 500000
max_output_bytes: 32000
allow_private_ips: true
denylist:
- evil.com
Expand All @@ -168,7 +182,8 @@ content_validation:

assert_eq!(cfg.allowed_schemes, vec!["https", "http"]);
assert_eq!(cfg.timeout_secs, 30);
assert_eq!(cfg.max_bytes, 50_000);
assert_eq!(cfg.max_fetch_bytes, 500_000);
assert_eq!(cfg.max_output_bytes, 32_000);
assert!(cfg.allow_private_ips);
assert_eq!(cfg.denylist, vec!["evil.com"]);
assert_eq!(cfg.allowlist, vec!["safe.org"]);
Expand All @@ -184,7 +199,8 @@ content_validation:

assert_eq!(cfg.allowed_schemes, vec!["https"]);
assert_eq!(cfg.timeout_secs, 15);
assert_eq!(cfg.max_bytes, 64 * 1024);
assert_eq!(cfg.max_fetch_bytes, 512 * 1024);
assert_eq!(cfg.max_output_bytes, 64 * 1024);
assert!(cfg.content_validation.enabled);
}

Expand Down Expand Up @@ -219,18 +235,69 @@ allowlist:
}

#[test]
fn config_normalize_zero_max_bytes() {
fn config_normalize_zero_max_fetch_bytes() {
let yaml = r#"
max_bytes: 0
max_fetch_bytes: 0
max_output_bytes: 0
timeout_secs: 0
"#;
let cfg: WebFetchConfig = yaml_serde::from_str(yaml).expect("deserialize");
assert_eq!(cfg.max_bytes, 0);
assert_eq!(cfg.max_fetch_bytes, 0);
assert_eq!(cfg.max_output_bytes, 0);
assert_eq!(cfg.timeout_secs, 0);

let normalized = cfg.normalize();

assert_eq!(normalized.max_bytes, 64 * 1024);
assert_eq!(normalized.max_fetch_bytes, 512 * 1024);
assert_eq!(normalized.max_output_bytes, 64 * 1024);
assert_eq!(normalized.timeout_secs, 15);
}

#[test]
fn config_normalize_preserves_nonzero_values() {
let yaml = r#"
max_fetch_bytes: 100000
max_output_bytes: 50000
timeout_secs: 30
"#;
let cfg: WebFetchConfig = yaml_serde::from_str(yaml).expect("deserialize");
let normalized = cfg.normalize();

assert_eq!(normalized.max_fetch_bytes, 100_000);
assert_eq!(normalized.max_output_bytes, 50_000);
assert_eq!(normalized.timeout_secs, 30);
}

#[test]
fn config_normalize_raises_max_scan_bytes_to_max_output_bytes() {
let yaml = r#"
max_output_bytes: 200000
content_validation:
max_scan_bytes: 10000
"#;
let cfg: WebFetchConfig = yaml_serde::from_str(yaml).expect("deserialize");
assert_eq!(cfg.max_output_bytes, 200_000);
assert_eq!(cfg.content_validation.max_scan_bytes, 10_000);

let normalized = cfg.normalize();

assert_eq!(normalized.max_output_bytes, 200_000);
assert_eq!(
normalized.content_validation.max_scan_bytes, 200_000,
"max_scan_bytes must be raised to max_output_bytes"
);
}

#[test]
fn config_normalize_preserves_max_scan_bytes_when_already_sufficient() {
let yaml = r#"
max_output_bytes: 50000
content_validation:
max_scan_bytes: 100000
"#;
let cfg: WebFetchConfig = yaml_serde::from_str(yaml).expect("deserialize");
let normalized = cfg.normalize();

assert_eq!(normalized.content_validation.max_scan_bytes, 100_000);
}
}
Loading