feat: stealth status self-check command (issue #5)
Local stealth verification with no external detector: reports mode (connect vs launch), live fingerprint probes (navigator.webdriver / window.chrome / plugins / UA-headless) as pass/fail, and an audit of the active overrides for the path (incl. the iframe-proxy state from #4). `--json` for a stable shape agents can gate a sensitive flow on. Distinct from `doctor` (install/env health).
This commit is contained in:
@@ -931,6 +931,15 @@ fn parse_command_inner(args: &[String], flags: &Flags) -> Result<Value, ParseErr
|
|||||||
Ok(json!({ "id": id, "action": "evaluate", "script": script }))
|
Ok(json!({ "id": id, "action": "evaluate", "script": script }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Stealth self-check ===
|
||||||
|
"stealth" => {
|
||||||
|
// `stealth [status]` — local stealth self-check: mode, live probes
|
||||||
|
// (navigator.webdriver, window.chrome, plugins, UA), and the list of
|
||||||
|
// active overrides. --json for a stable machine-readable shape.
|
||||||
|
// (Distinct from `doctor`, which checks install/env/Chrome health.)
|
||||||
|
Ok(json!({ "id": id, "action": "stealth_status" }))
|
||||||
|
}
|
||||||
|
|
||||||
// === Close ===
|
// === Close ===
|
||||||
"close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "close" })),
|
"close" | "quit" | "exit" => Ok(json!({ "id": id, "action": "close" })),
|
||||||
|
|
||||||
|
|||||||
@@ -1316,6 +1316,7 @@ pub async fn execute_command(cmd: &Value, state: &mut DaemonState) -> Value {
|
|||||||
"content" => handle_content(state).await,
|
"content" => handle_content(state).await,
|
||||||
"evaluate" => handle_evaluate(cmd, state).await,
|
"evaluate" => handle_evaluate(cmd, state).await,
|
||||||
"close" => handle_close(state).await,
|
"close" => handle_close(state).await,
|
||||||
|
"stealth_status" => handle_stealth_status(state).await,
|
||||||
"snapshot" => handle_snapshot(cmd, state).await,
|
"snapshot" => handle_snapshot(cmd, state).await,
|
||||||
"screenshot" => handle_screenshot(cmd, state).await,
|
"screenshot" => handle_screenshot(cmd, state).await,
|
||||||
"click" => handle_click(cmd, state).await,
|
"click" => handle_click(cmd, state).await,
|
||||||
@@ -2680,6 +2681,89 @@ async fn handle_evaluate(cmd: &Value, state: &DaemonState) -> Result<Value, Stri
|
|||||||
Ok(json!({ "result": result, "origin": url }))
|
Ok(json!({ "result": result, "origin": url }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Local stealth self-check: reports the active mode, live fingerprint probes,
|
||||||
|
/// and the list of applied overrides — so an agent (or human) can confirm
|
||||||
|
/// stealth is working without driving an external detector, and audit exactly
|
||||||
|
/// what's patched on this path (issue #5).
|
||||||
|
async fn handle_stealth_status(state: &DaemonState) -> Result<Value, String> {
|
||||||
|
let mgr = state.browser.as_ref().ok_or("Browser not launched")?;
|
||||||
|
let connect = mgr.is_cdp_connection();
|
||||||
|
|
||||||
|
let probe_js = r#"(() => {
|
||||||
|
const ua = navigator.userAgent || '';
|
||||||
|
return {
|
||||||
|
webdriver: navigator.webdriver === true,
|
||||||
|
hasWindowChrome: typeof window.chrome === 'object' && window.chrome !== null,
|
||||||
|
plugins: navigator.plugins ? navigator.plugins.length : 0,
|
||||||
|
languages: navigator.languages || [],
|
||||||
|
platform: navigator.platform || '',
|
||||||
|
headlessUA: /Headless/i.test(ua),
|
||||||
|
};
|
||||||
|
})()"#;
|
||||||
|
let p = mgr.evaluate(probe_js, None).await.unwrap_or(Value::Null);
|
||||||
|
let webdriver = p.get("webdriver").and_then(|v| v.as_bool()).unwrap_or(true);
|
||||||
|
let has_chrome = p
|
||||||
|
.get("hasWindowChrome")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
let plugins = p.get("plugins").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
let headless_ua = p
|
||||||
|
.get("headlessUA")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
let checks = json!([
|
||||||
|
{ "name": "navigator.webdriver is false", "pass": !webdriver },
|
||||||
|
{ "name": "window.chrome present", "pass": has_chrome },
|
||||||
|
{ "name": "navigator.plugins non-empty", "pass": plugins > 0, "value": plugins },
|
||||||
|
{ "name": "userAgent has no 'Headless'", "pass": !headless_ua },
|
||||||
|
]);
|
||||||
|
let ok = !webdriver && has_chrome && plugins > 0 && !headless_ua;
|
||||||
|
|
||||||
|
let overrides = if connect {
|
||||||
|
json!([
|
||||||
|
"navigator.webdriver=false via Emulation.setAutomationOverride (native CDP — no JS lie)",
|
||||||
|
"Runtime.enable OFF unless console/error capture is opted in (no rebrowser runtime leak)",
|
||||||
|
"zero JS patches injected — the browser's real fingerprint is used as-is",
|
||||||
|
])
|
||||||
|
} else {
|
||||||
|
let iframe_proxy =
|
||||||
|
std::env::var("AGENT_BROWSER_DISABLE_IFRAME_PROXY").as_deref() != Ok("1");
|
||||||
|
json!([
|
||||||
|
"navigator.webdriver removed; navigator.languages/locale normalized",
|
||||||
|
"window.chrome / chrome.runtime shimmed; navigator.platform fixed",
|
||||||
|
"WebGL vendor/renderer, plugins, permissions normalized",
|
||||||
|
format!(
|
||||||
|
"srcdoc-iframe contentWindow proxy: {} (CreepJS hasIframeProxy)",
|
||||||
|
if iframe_proxy {
|
||||||
|
"ON — set AGENT_BROWSER_DISABLE_IFRAME_PROXY=1 for clean 0%"
|
||||||
|
} else {
|
||||||
|
"off"
|
||||||
|
}
|
||||||
|
),
|
||||||
|
format!(
|
||||||
|
"canvas/audio noise: {} (AGENT_BROWSER_HIDE_CANVAS)",
|
||||||
|
if std::env::var("AGENT_BROWSER_HIDE_CANVAS").as_deref() == Ok("1") {
|
||||||
|
"on"
|
||||||
|
} else {
|
||||||
|
"off (opt-in)"
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"Chrome flags: --disable-blink-features=AutomationControlled, ANGLE GL",
|
||||||
|
])
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"stealthStatus": {
|
||||||
|
"mode": if connect { "connect (your real Chrome — strongest)" } else { "launch (standalone)" },
|
||||||
|
"ok": ok,
|
||||||
|
"checks": checks,
|
||||||
|
"overrides": overrides,
|
||||||
|
"probe": p,
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
async fn handle_close(state: &mut DaemonState) -> Result<Value, String> {
|
async fn handle_close(state: &mut DaemonState) -> Result<Value, String> {
|
||||||
if let Some(ref mgr) = state.browser {
|
if let Some(ref mgr) = state.browser {
|
||||||
if let Some(ref session_name) = state.session_name {
|
if let Some(ref session_name) = state.session_name {
|
||||||
|
|||||||
@@ -352,6 +352,39 @@ pub fn print_response_with_opts(resp: &Response, action: Option<&str>, opts: &Ou
|
|||||||
println!("{}", enabled);
|
println!("{}", enabled);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Stealth self-check (`stealth status` / `doctor`)
|
||||||
|
if let Some(s) = data.get("stealthStatus") {
|
||||||
|
let ok = s.get("ok").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
let mode = s.get("mode").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
println!(
|
||||||
|
"{} stealth: {} · mode: {}",
|
||||||
|
if ok {
|
||||||
|
color::success_indicator().to_string()
|
||||||
|
} else {
|
||||||
|
color::cyan("•")
|
||||||
|
},
|
||||||
|
if ok {
|
||||||
|
"all checks pass"
|
||||||
|
} else {
|
||||||
|
"some checks need attention"
|
||||||
|
},
|
||||||
|
mode
|
||||||
|
);
|
||||||
|
if let Some(checks) = s.get("checks").and_then(|v| v.as_array()) {
|
||||||
|
for c in checks {
|
||||||
|
let pass = c.get("pass").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||||
|
let name = c.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
println!(" {} {}", if pass { "✓" } else { "✗" }, name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ovs) = s.get("overrides").and_then(|v| v.as_array()) {
|
||||||
|
println!(" applied overrides:");
|
||||||
|
for o in ovs.iter().filter_map(|v| v.as_str()) {
|
||||||
|
println!(" {}", color::dim(&format!("· {o}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if let Some(checked) = data.get("checked").and_then(|v| v.as_bool()) {
|
if let Some(checked) = data.get("checked").and_then(|v| v.as_bool()) {
|
||||||
println!("{}", checked);
|
println!("{}", checked);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -520,6 +520,10 @@ agent-browser doctor # full diagnosis (env, Chrome, daemons,
|
|||||||
agent-browser doctor --offline --quick # fast, local-only
|
agent-browser doctor --offline --quick # fast, local-only
|
||||||
agent-browser doctor --fix # also run destructive repairs (reinstall Chrome, purge old state, ...)
|
agent-browser doctor --fix # also run destructive repairs (reinstall Chrome, purge old state, ...)
|
||||||
agent-browser doctor --json # structured output for programmatic consumption
|
agent-browser doctor --json # structured output for programmatic consumption
|
||||||
|
agent-browser stealth status # stealth self-check: mode + live probes
|
||||||
|
agent-browser stealth status --json # (webdriver/chrome/plugins/UA) + applied
|
||||||
|
# overrides. Gate a sensitive flow on this
|
||||||
|
# instead of driving an external detector.
|
||||||
```
|
```
|
||||||
|
|
||||||
`doctor` auto-cleans stale socket/pid/version sidecar files on every run.
|
`doctor` auto-cleans stale socket/pid/version sidecar files on every run.
|
||||||
|
|||||||
Reference in New Issue
Block a user