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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Fix root cause. Unsure: read more code; if stuck, ask w/ short options. Unrecogn
| formats/format-support | Formats, bytes-in API, detection order, DOCX + spreadsheet mapping |
| integrations/tool-contract | `MarkkitTool` LLM tool (`tool` feature), Everruns toolkit contract |
| security/security-model | One-page threat/invariant summary |
| security/threat-model | Threats T1-T6, mitigations, test map, resource limits, invariants S1-S17 |
| security/threat-model | Threats T1-T9, mitigations, test map, resource limits, invariants S1-S20 |
| runtimes/python-package | PyO3 `markkit` module, maturin, PyPI |
| runtimes/wasm-package | wasm-bindgen `@everruns/markkit-wasm`, browser/edge, build + tests |
| runtimes/node-package | NAPI-RS `@everruns/markkit`, npm, binding parity table |
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- The JSON IR of DOCX and spreadsheet tables now holds raw text (pipes were pre-escaped before).
- Math TeX (taken from page markup) renders as code, GitHub's `` $`tex`$ `` and ` ```math `
forms, instead of `$tex$` / `$$`, so it can no longer emit raw HTML or links in plain GFM.
- Hidden content is skipped by default as a prompt-injection defence: HTML elements with `hidden`,
`aria-hidden="true"` or inline `display:none` / `visibility:hidden` / `opacity:0` / `font-size:0`,
`<template>`, and DOCX hidden (`w:vanish`) runs (warning `hidden_content_skipped`). Opt back in
with `include_hidden_content(true)`.

### Added

Expand Down Expand Up @@ -154,6 +158,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- XLSX packages missing `_rels/.rels` no longer fail inside calamine.
- Release builds use `panic = "unwind"`, so a parser panic becomes an error instead of aborting the
CLI or the host Python/Node process.
- Exclude selectors now apply to `<li>`, `<tr>`, `<th>` and `<td>`; they were ignored there.
- Indented HTML (most real web pages) now keeps blank lines between blocks; previously
headings, paragraphs, lists and tables after the first ran together with a leading space
(`# A\n B` instead of `# A\n\nB`), which also broke headings and tables as markdown.
Expand Down
13 changes: 13 additions & 0 deletions crates/markkit/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ pub struct MarkkitBuilder {
pub exclude_selectors: Vec<String>,
/// Render hidden / very hidden sheets. Default: false (skipped with a warning).
pub include_hidden_sheets: bool,
/// Render content a reader never sees: HTML `hidden`, `aria-hidden="true"`,
/// inline `display:none` / `visibility:hidden` / `opacity:0` / `font-size:0`,
/// and DOCX `w:vanish` runs. Default: false (prompt-injection vector, T9).
pub include_hidden_content: bool,
/// Let `extract_url` reach loopback, private, link-local and other
/// non-public addresses. Default: false (SSRF protection).
pub allow_private_networks: bool,
Expand Down Expand Up @@ -63,6 +67,7 @@ impl Default for MarkkitBuilder {
"noscript".into(),
],
include_hidden_sheets: false,
include_hidden_content: false,
allow_private_networks: false,
allowed_hosts: Vec::new(),
base_url: None,
Expand Down Expand Up @@ -192,6 +197,14 @@ impl MarkkitBuilder {
self
}

/// Render hidden content (HTML hidden elements, DOCX hidden runs) instead of
/// skipping it. Hidden text is a prompt-injection vector; keep the default
/// (`false`) for content fed to LLMs.
pub fn include_hidden_content(mut self, yes: bool) -> Self {
self.include_hidden_content = yes;
self
}

/// Allow `extract_url` to reach non-public addresses (loopback, RFC 1918,
/// link-local, cloud metadata, ...). Only for trusted URLs.
pub fn allow_private_networks(mut self, yes: bool) -> Self {
Expand Down
20 changes: 19 additions & 1 deletion crates/markkit/src/docx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ use crate::xml::{self, Element, XmlError};
/// Result of parsing a DOCX package.
pub(crate) struct DocxOutput {
pub doc: Document,
pub warnings: Vec<String>,
pub title: Option<String>,
pub paragraphs: usize,
pub tables: usize,
Expand Down Expand Up @@ -377,6 +378,10 @@ struct Ctx<'a> {
styles: &'a Styles,
numbering: &'a Numbering,
budget: &'a Budget,
/// Render `w:vanish` runs (threat model T9).
include_hidden: bool,
/// Hidden runs skipped so far.
hidden_runs: std::cell::Cell<usize>,
}

impl Ctx<'_> {
Expand Down Expand Up @@ -415,7 +420,12 @@ impl Ctx<'_> {
}

fn run_segs(&self, run: &Element, link: Option<&str>, segs: &mut Vec<Seg>) {
let fmt = run_fmt(run.child("rPr"));
let rpr = run.child("rPr");
if !self.include_hidden && toggle_on(rpr.and_then(|r| r.child("vanish"))) {
self.hidden_runs.set(self.hidden_runs.get() + 1);
return;
}
let fmt = run_fmt(rpr);
let link = link.map(str::to_string);
let push_text = |segs: &mut Vec<Seg>, t: String| {
segs.push(Seg::Text {
Expand Down Expand Up @@ -966,6 +976,8 @@ pub(crate) fn parse(
styles: &styles,
numbering: &numbering,
budget,
include_hidden: config.include_hidden_content,
hidden_runs: std::cell::Cell::new(0),
};
let mut body = Body {
track: config.track_sources,
Expand All @@ -980,6 +992,12 @@ pub(crate) fn parse(
paragraphs: body.paragraphs,
tables: body.tables,
doc: Document::new(body.blocks),
warnings: match ctx.hidden_runs.get() {
0 => Vec::new(),
n => vec![format!(
"hidden_content_skipped: {n} hidden text run(s) skipped"
)],
},
})
}

Expand Down
1 change: 1 addition & 0 deletions crates/markkit/src/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ impl Markkit {
self.config.transforms.apply(&mut doc)?;
let mut p = ParsedDocument::new(doc, Format::Docx);
p.title = out.title;
p.warnings = out.warnings;
p.stats.paragraphs = Some(out.paragraphs);
p.stats.tables = Some(out.tables);
Ok(p)
Expand Down
59 changes: 55 additions & 4 deletions crates/markkit/src/html/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,45 @@ use url::Url;
use super::metadata::PageInfo;
use super::{readability, urls};

/// Hidden-content policy (threat model T9): an element a reader never sees is
/// skipped unless `include_hidden_content(true)`. Only signals on the element
/// itself count (`hidden`, `aria-hidden="true"`, inline `style`); stylesheet and
/// class-based hiding (`.sr-only`, off-screen positioning) is not detected.
fn is_hidden(el: &scraper::node::Element) -> bool {
if el
.attr("hidden")
.is_some_and(|v| !v.eq_ignore_ascii_case("until-found"))
{
return true;
}
if el
.attr("aria-hidden")
.is_some_and(|v| v.trim().eq_ignore_ascii_case("true"))
{
return true;
}
let Some(style) = el.attr("style") else {
return false;
};
style.split(';').any(|decl| {
let Some((prop, value)) = decl.split_once(':') else {
return false;
};
let prop = prop.trim().to_ascii_lowercase();
let value = value.to_ascii_lowercase();
let value = value.trim().trim_end_matches("!important").trim();
match prop.as_str() {
"display" => value == "none",
"visibility" => value == "hidden" || value == "collapse",
"opacity" | "font-size" => {
let num = value.trim_end_matches(|c: char| c.is_ascii_alphabetic() || c == '%');
num.parse::<f64>().is_ok_and(|n| n == 0.0)
}
_ => false,
}
})
}

/// Outcome of a matching element rule.
enum RuleHit {
Skip,
Expand Down Expand Up @@ -271,6 +310,7 @@ impl<'a> HtmlParser<'a> {
fn is_excluded(&self, el: &scraper::ElementRef<'_>, exclude: &[Selector]) -> bool {
exclude.iter().any(|sel| sel.matches(el))
|| (self.drop_unlikely.get() && readability::is_unlikely(*el))
|| (!self.config.include_hidden_content && is_hidden(el.value()))
}

fn convert_children(
Expand Down Expand Up @@ -569,9 +609,9 @@ impl<'a> HtmlParser<'a> {
"hr" => Some(Node::ThematicBreak),

// Skip non-content elements
"script" | "style" | "noscript" | "iframe" | "object" | "embed" | "form" | "input"
| "button" | "select" | "textarea" | "svg" | "canvas" | "video" | "audio"
| "source" | "track" | "map" | "area" => None,
"script" | "style" | "noscript" | "template" | "iframe" | "object" | "embed"
| "form" | "input" | "button" | "select" | "textarea" | "svg" | "canvas" | "video"
| "audio" | "source" | "track" | "map" | "area" => None,

// Unknown element: try to extract inline content
_ => {
Expand Down Expand Up @@ -967,6 +1007,7 @@ impl<'a> HtmlParser<'a> {
for child in list_el.children() {
if let Some(el) = scraper::ElementRef::wrap(child)
&& el.value().name() == "li"
&& !self.is_excluded(&el, exclude)
{
let mut children = Vec::new();
self.convert_children(el, exclude, &mut children, depth + 1);
Expand Down Expand Up @@ -1026,6 +1067,9 @@ impl<'a> HtmlParser<'a> {
};

for tr in body_rows {
if self.is_excluded(&tr, exclude) {
continue;
}
let row = row_cells(tr, &["td", "th"]);
// A leading all-<th> row is the header.
if headers.is_empty()
Expand Down Expand Up @@ -1080,7 +1124,14 @@ impl<'a> HtmlParser<'a> {
n.min(MAX_ROWSPAN)
}
});
TableCell::new(self.collect_inline(cell, exclude, depth + 1)).with_span(colspan, rowspan)
// An excluded or hidden cell stays as an empty cell so columns keep
// their alignment.
let children = if self.is_excluded(&cell, exclude) {
Vec::new()
} else {
self.collect_inline(cell, exclude, depth + 1)
};
TableCell::new(children).with_span(colspan, rowspan)
}
}

Expand Down
129 changes: 129 additions & 0 deletions crates/markkit/tests/hidden_content.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
//! Hidden content (threat model T9): text a human reader never sees is a
//! prompt-injection vector, so it is skipped unless `include_hidden_content(true)`.

#[cfg(feature = "docx")]
mod common;

#[cfg(feature = "html")]
mod html {
use markkit::Markkit;

fn convert(html: &str) -> String {
Markkit::builder().build().extract_html_sync(html).unwrap()
}

fn convert_all(html: &str) -> String {
Markkit::builder()
.include_hidden_content(true)
.build()
.extract_html_sync(html)
.unwrap()
}

const HIDDEN: &[&str] = &[
r#"<div style="display:none">INJECT</div>"#,
r#"<div style="DISPLAY : None !important">INJECT</div>"#,
r#"<p style="color:red;visibility:hidden">INJECT</p>"#,
r#"<span style="visibility: collapse">INJECT</span>"#,
r#"<span style="font-size:0">INJECT</span>"#,
r#"<span style="font-size: 0px">INJECT</span>"#,
r#"<span style="opacity:0">INJECT</span>"#,
r#"<span style="opacity: 0.0">INJECT</span>"#,
r#"<div hidden>INJECT</div>"#,
r#"<div aria-hidden="true">INJECT</div>"#,
r#"<template><p>INJECT</p></template>"#,
r#"<p>visible <span style="display:none">INJECT</span> text</p>"#,
r#"<ul><li style="display:none">INJECT</li><li>visible</li></ul>"#,
r#"<table><tr><td>a</td><td style="display:none">INJECT</td></tr></table>"#,
];

#[test]
fn hidden_elements_skipped_by_default() {
for html in HIDDEN {
let md = convert(&format!("<p>visible</p>{html}"));
assert!(!md.contains("INJECT"), "{html} -> {md:?}");
assert!(md.contains("visible"), "{html} -> {md:?}");
}
}

#[test]
fn hidden_elements_kept_when_opted_in() {
for html in HIDDEN.iter().filter(|h| !h.starts_with("<template")) {
let md = convert_all(html);
assert!(md.contains("INJECT"), "{html} -> {md:?}");
}
}

#[test]
fn visible_styles_are_not_hidden() {
for html in [
r#"<p style="display:block">SHOWN</p>"#,
r#"<p style="opacity:0.5">SHOWN</p>"#,
r#"<p style="font-size:10px">SHOWN</p>"#,
r#"<p style="font-size:0.9em">SHOWN</p>"#,
r#"<p aria-hidden="false">SHOWN</p>"#,
r#"<p hidden="until-found">SHOWN</p>"#,
r#"<p class="hidden sr-only">SHOWN</p>"#,
r#"<p data-style="display:none">SHOWN</p>"#,
] {
let md = convert(html);
assert!(md.contains("SHOWN"), "{html} -> {md:?}");
}
}
}

#[cfg(feature = "docx")]
mod docx {
use super::common::*;
use markkit::{DocumentHint, Markkit};

fn run(props: &str, text: &str) -> String {
format!("<w:r><w:rPr>{props}</w:rPr><w:t>{text}</w:t></w:r>")
}

fn md(body: &str, include: bool) -> markkit::Extraction {
Markkit::builder()
.include_hidden_content(include)
.build()
.extract_document_sync(&docx(body), DocumentHint::file_name("a.docx"))
.unwrap()
}

#[test]
fn vanish_runs_skipped_by_default() {
let body = format!(
"<w:p>{}{}</w:p><w:p>{}</w:p>",
run("", "shown "),
run("<w:vanish/>", "INJECT"),
run("<w:vanish w:val=\"true\"/>", "INJECT2"),
);
let e = md(&body, false);
assert_eq!(e.markdown, "shown\n");
assert!(
e.warnings
.iter()
.any(|w| w.starts_with("hidden_content_skipped")),
"{:?}",
e.warnings
);
}

#[test]
fn vanish_off_is_visible() {
let e = md(
&format!("<w:p>{}</w:p>", run("<w:vanish w:val=\"0\"/>", "shown")),
false,
);
assert_eq!(e.markdown, "shown\n");
assert!(e.warnings.is_empty());
}

#[test]
fn vanish_runs_kept_when_opted_in() {
let e = md(
&format!("<w:p>{}</w:p>", run("<w:vanish/>", "secret")),
true,
);
assert_eq!(e.markdown, "secret\n");
}
}
12 changes: 12 additions & 0 deletions crates/markkit/tests/html_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,18 @@ fn include_selector() {
assert!(!md.contains("Sidebar"));
}

#[test]
fn exclude_selector_applies_to_list_items_and_table_parts() {
let html = r#"<ul><li class="ad">AD</li><li>keep</li></ul>
<table><tr><th>h1</th><th>h2</th></tr><tr class="ad"><td>AD</td><td>AD</td></tr>
<tr><td class="ad">AD</td><td>cell</td></tr></table>"#;
let md = convert_with(html, |b| b.exclude_selector(".ad"));
assert!(!md.contains("AD"), "{md}");
assert!(md.contains("- keep"), "{md}");
// Excluded cell stays as an empty cell so columns keep alignment.
assert!(md.contains("| | cell |"), "{md}");
}

#[test]
fn exclude_selector_custom() {
let html = r#"<p>Keep this</p><div class="ad">Buy stuff</div><p>And this</p>"#;
Expand Down
3 changes: 2 additions & 1 deletion knowledge/formats/format-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ only the Markdown. Parsing is CPU-bound; async callers should use the sync varia
`spawn_blocking`.

Warning codes: `truncated_sheet`, `hidden_sheet_skipped`, `sheet_error`, `truncated_pdf`,
`pdf_unmapped_font`, `pdf_no_text`, `invalid_utf8`.
`pdf_unmapped_font`, `pdf_no_text`, `invalid_utf8`, `hidden_content_skipped`.

## Format Detection

Expand Down Expand Up @@ -113,6 +113,7 @@ mislabelled as another Excel type still parses.
| `w:br` / `w:cr` | Hard line break (page/column breaks ignored); `w:tab` → space |
| `w:ins`, `w:sdt`, `w:smartTag`, `w:customXml`, `w:fldSimple` | Unwrapped |
| `w:del`, `w:instrText`, drawings/text boxes, comments, headers/footers, footnotes | Skipped |
| Runs with direct `w:vanish` (hidden text) | Skipped with `hidden_content_skipped` unless `include_hidden_content(true)` |

The main part is located through `_rels/.rels` (fallback `word/document.xml`); styles and
numbering through the main part's relationships.
Expand Down
Loading
Loading