pi-chrome 0.15.28 → 0.15.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable user-facing changes to `pi-chrome`.
4
4
 
5
+ ## 0.15.29 — 2026-05-31
6
+
7
+ Strict-CSP support: `chrome_evaluate`, `chrome_snapshot`, `chrome_wait_for`, and `chrome_navigate initScript` now work on pages that block `unsafe-eval`.
8
+
9
+ - **CDP-based evaluation bypasses page CSP.** `chrome_evaluate`/`chrome_snapshot` (and all snapshot-driven inspection) previously ran user code in the page MAIN world via the **Function constructor**, which is blocked by `script-src 'self'` without `'unsafe-eval'` — so they returned null/empty (or `EvalError`) on github.com and many bank/SaaS apps. They now evaluate through CDP `Runtime.evaluate`, a DevTools protocol command that is not subject to the page's Content-Security-Policy. Rich return values (undefined/function/symbol/bigint/Error markers, DOMRect expansion) and the expression/statement fallback are preserved.
10
+ - **`chrome_wait_for` polls via CDP.** The selector/expression polling loop moved from in-page `new Function()` to service-worker-side CDP evaluation, so waits work under strict CSP too.
11
+ - **`chrome_navigate initScript` injects via CDP.** Document-start init scripts now register with `Page.addScriptToEvaluateOnNewDocument` instead of `new Function()` on `webNavigation.onCommitted`, so seeding localStorage / stubbing `Date.now` works under strict CSP.
12
+ - **Tests.** Added a Node unit harness (`test-suite/unit/csp-eval.test.mjs`, run via `npm test`) validating the evaluate/execute/waitFor refactor, and an in-browser regression challenge (42 `strict-csp-evaluate`) that reads a JS-only secret under strict CSP. Updated challenge 39's notes and docs (FAQ, EXAMPLES, COMPARISON, primer) which previously stated eval/snapshot fail under strict CSP.
13
+
5
14
  ## 0.15.28 — 2026-05-31
6
15
 
7
16
  Low-risk reliability fixes from a long-session bug report.
package/README.md CHANGED
@@ -217,7 +217,7 @@ Multiple Pi sessions (planner / worker / audit) can all drive the same Chrome at
217
217
 
218
218
  ## Built-in benchmark suite
219
219
 
220
- [`test-suite/`](./test-suite) is a benchmark for **any** browser-control agent (not just pi-chrome). It includes **41 primitive challenges** plus **4 hermetic BrowserGym-style long-horizon tasks**.
220
+ [`test-suite/`](./test-suite) is a benchmark for **any** browser-control agent (not just pi-chrome). It includes **42 primitive challenges** plus **4 hermetic BrowserGym-style long-horizon tasks**.
221
221
 
222
222
  Scoring tracks expected outcomes per challenge rather than raw PASS count, so tools are judged against their declared browser-control capability. Unit challenges are split into gate buckets:
223
223
 
@@ -134,7 +134,7 @@ If your threat model excludes extensions with broad permissions, neither approac
134
134
 
135
135
  ## Public benchmarks worth knowing (for axis 2 / axis 3 comparison)
136
136
 
137
- Pi-chrome itself ships a benchmark suite ([`../test-suite/`](../test-suite)) of **41 primitive challenges** plus **4 hermetic BrowserGym-style long-horizon tasks** covering trusted input, pointer humanization, keyboard fidelity, drag/drop, Shadow DOM, iframes, file uploads, strict-CSP screenshot fallback, dynamic waits, tab lifecycle, network observability, fingerprint leaks, and agent-safety honeypots. Scoring tracks expected outcomes per challenge instead of raw PASS count, with `core`, `conditional`, and `quality` gate buckets. That's **driver-level** grading.
137
+ Pi-chrome itself ships a benchmark suite ([`../test-suite/`](../test-suite)) of **42 primitive challenges** plus **4 hermetic BrowserGym-style long-horizon tasks** covering trusted input, pointer humanization, keyboard fidelity, drag/drop, Shadow DOM, iframes, file uploads, strict-CSP screenshot fallback and CDP eval/snapshot bypass, dynamic waits, tab lifecycle, network observability, fingerprint leaks, and agent-safety honeypots. Scoring tracks expected outcomes per challenge instead of raw PASS count, with `core`, `conditional`, and `quality` gate buckets. That's **driver-level** grading.
138
138
 
139
139
  For **agent-level** comparison (axis 2), the public benchmarks worth citing:
140
140
 
package/docs/EXAMPLES.md CHANGED
@@ -161,6 +161,6 @@ Interactive tools use Chrome's real input layer by default: clicks, typing, fill
161
161
  - fullscreen and other user-activation checks
162
162
  - pages where DOM injection/evaluate is limited, if the agent can use screenshots + coordinates
163
163
 
164
- Strict CSP note: `chrome_snapshot`/`chrome_evaluate` may be blocked on pages that disallow `unsafe-eval`; `chrome_screenshot`, tab/navigation tools, and real input still work.
164
+ Strict CSP note: `chrome_snapshot`/`chrome_evaluate` work even on pages that disallow `unsafe-eval`, because they run via CDP `Runtime.evaluate` (not page-level `eval`/`new Function`), which is not subject to page CSP. `chrome_screenshot`, tab/navigation tools, and real input also work under any CSP.
165
165
 
166
166
  Chrome may show its debugger banner while pi-chrome is attached.
package/docs/FAQ.md CHANGED
@@ -55,7 +55,7 @@ pi-chrome controls web pages through Chrome extension APIs, page inspection, scr
55
55
 
56
56
  ## Does `chrome_evaluate` work on strict-CSP pages?
57
57
 
58
- Not always. `chrome_evaluate` and `chrome_snapshot` run in the page's MAIN world through the Function constructor, so pages whose CSP blocks `'unsafe-eval'` can reject them. `chrome_screenshot`, `chrome_navigate`, tab tools, and real Chrome input still work because they use extension/browser APIs rather than page JavaScript.
58
+ Yes. `chrome_evaluate` and `chrome_snapshot` run in the page's MAIN world through CDP `Runtime.evaluate`, which is a DevTools protocol command and is **not** subject to the page's Content-Security-Policy. They work even on pages that block `'unsafe-eval'` (e.g. github.com and many bank/SaaS apps). `chrome_navigate`'s `initScript` injects at document_start via CDP and likewise bypasses CSP. `chrome_screenshot`, tab tools, and real Chrome input also keep working under any CSP.
59
59
 
60
60
  ## How do I tell whether a click or type worked?
61
61
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Pi Chrome Connector",
4
- "version": "0.15.28",
4
+ "version": "0.15.29",
5
5
  "description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.",
6
6
  "permissions": [
7
7
  "tabs",
@@ -217,6 +217,38 @@ async function cdp(tabId, method, params) {
217
217
  }
218
218
  }
219
219
 
220
+ // cdpEval: evaluate a JavaScript expression string in the page's MAIN world via CDP
221
+ // Runtime.evaluate. Runtime.evaluate is a DevTools protocol command and is NOT subject to
222
+ // the page's Content-Security-Policy, so it works on pages that ship `script-src 'self'`
223
+ // without `'unsafe-eval'` (which blocks `eval`/`new Function`). Ensures the debugger is
224
+ // attached first. Returns the raw CDP result ({ result, exceptionDetails }).
225
+ async function cdpEval(tabId, expression, opts) {
226
+ await attachDebugger(tabId);
227
+ return cdp(tabId, "Runtime.evaluate", {
228
+ expression,
229
+ returnByValue: true,
230
+ awaitPromise: true,
231
+ userGesture: true,
232
+ ...(opts || {}),
233
+ });
234
+ }
235
+
236
+ function cdpExceptionText(details) {
237
+ if (!details) return "";
238
+ return String(
239
+ details.exception?.description ||
240
+ details.exception?.value ||
241
+ details.text ||
242
+ "",
243
+ );
244
+ }
245
+
246
+ function cdpIsSyntaxError(details) {
247
+ if (!details) return false;
248
+ const className = String(details.exception?.className || "");
249
+ return className === "SyntaxError" || /SyntaxError/.test(cdpExceptionText(details));
250
+ }
251
+
220
252
  // Resolve target -> {x, y, rect} in viewport coords by running tiny script in tab.
221
253
  async function resolveTargetInTab(tabId, params) {
222
254
  const results = await chrome.scripting.executeScript({
@@ -739,8 +771,29 @@ async function dispatch(action, params) {
739
771
  return executeInTab(params, listNetworkRequests, [params.includePreservedRequests === true, params.clear === true]);
740
772
  case "page.network.get":
741
773
  return executeInTab(params, getNetworkRequest, [params.requestId]);
742
- case "page.waitFor":
743
- return executeInTab(params, waitForPage, [params.kind, params.value, params.timeoutMs || 10000, params.intervalMs || 250]);
774
+ case "page.waitFor": {
775
+ // Poll from the service worker via CDP (bypasses CSP). The old approach ran the polling
776
+ // loop in-page with new Function() for expression checks, which fails under strict CSP.
777
+ const tab = await getTabByParams(params);
778
+ if (params.foreground) await bringToFront(tab);
779
+ const timeoutMs = params.timeoutMs || 10000;
780
+ const intervalMs = params.intervalMs || 250;
781
+ const started = Date.now();
782
+ while (Date.now() - started < timeoutMs) {
783
+ let ok = false;
784
+ try {
785
+ const expr = params.kind === "selector"
786
+ ? `!!document.querySelector(${JSON.stringify(params.value)})`
787
+ : params.value;
788
+ ok = Boolean(await evaluateInTab({ ...params, expression: expr, foreground: false }));
789
+ } catch {
790
+ ok = false;
791
+ }
792
+ if (ok) return { elapsedMs: Date.now() - started };
793
+ await sleep(intervalMs);
794
+ }
795
+ throw new Error(`Timed out after ${timeoutMs}ms waiting for ${params.kind}: ${params.value}`);
796
+ }
744
797
  case "page.probe":
745
798
  // Lightweight capability probe for /chrome-doctor. Runs in MAIN world.
746
799
  return executeInTab(params, probePage, []);
@@ -847,25 +900,33 @@ const HELPER_FUNCS = [
847
900
  async function executeInTab(params, func, args) {
848
901
  const tab = await getTabByParams(params);
849
902
  if (params.foreground) await bringToFront(tab);
850
- const helperSource = HELPER_FUNCS.map((helper) => helper.toString()).join("\n");
903
+
904
+ // Phase 1: define the helpers and the action function as page globals via CDP
905
+ // Runtime.evaluate. This bypasses page CSP (no `eval`/`new Function`), which is the
906
+ // root cause of snapshot/click/etc silently failing on `script-src 'self'` sites.
907
+ // Each helper is a named function declaration, assigned to window.<name> so the action
908
+ // (which references helpers by bare name) resolves them as globals at call time.
909
+ const assignments = HELPER_FUNCS.map((helper) => `window.${helper.name}=${helper.toString()}`).join(";\n");
910
+ const actionAssign = `window.__piAction=(${func.toString()})`;
911
+ const defineRes = await cdpEval(tab.id, `(()=>{${assignments};\n${actionAssign};})()`);
912
+ if (defineRes.exceptionDetails) {
913
+ throw new Error(`Failed to inject Chrome page helpers: ${cdpExceptionText(defineRes.exceptionDetails) || "unknown error"}`);
914
+ }
915
+
916
+ // Phase 2: run the action via chrome.scripting.executeScript. The `func:` form is
917
+ // injected by Chrome itself (not `new Function`), so it is CSP-safe, and it lets Chrome
918
+ // serialize the invocation args. The wrapper references window.__piAction defined above.
851
919
  const results = await chrome.scripting.executeScript({
852
920
  target: { tabId: tab.id },
853
921
  world: "MAIN",
854
- func: async (helperSource, source, invocationArgs) => {
922
+ func: async (invocationArgs) => {
855
923
  try {
856
- // Helpers are plain function declarations; injecting them via Function constructor avoids
857
- // running through `eval` (which is restricted under strict CSP) and keeps them isolated.
858
- new Function(helperSource).call(globalThis);
859
- // The action itself is reconstructed from its source text. We use `new Function` rather
860
- // than `eval` because the latter is blocked by `script-src 'self'` (no `'unsafe-eval'`)
861
- // CSPs that are common on production sites.
862
- const injected = new Function(helperSource + "\nreturn (" + source + ");").call(globalThis);
863
- return { ok: true, value: await injected(...invocationArgs) };
924
+ return { ok: true, value: await window.__piAction(...invocationArgs) };
864
925
  } catch (error) {
865
926
  return { ok: false, error: error?.stack || error?.message || String(error) };
866
927
  }
867
928
  },
868
- args: [helperSource, func.toString(), args],
929
+ args: [args || []],
869
930
  });
870
931
  const first = results?.[0];
871
932
  if (first?.error) {
@@ -879,72 +940,54 @@ async function executeInTab(params, func, args) {
879
940
  return envelope?.value;
880
941
  }
881
942
 
882
- // Dedicated executor for page.evaluate. Doesn't go through the helper-source injection chain;
883
- // that chain was the root cause of `chrome_evaluate` silently returning null on pages with strict
884
- // CSP. We build a single Function in MAIN world and invoke it directly.
943
+ // Serializer for page.evaluate results. Embedded (via .toString()) into the CDP-evaluated
944
+ // expression so we can return rich markers for values that don't survive returnByValue
945
+ // (undefined/function/symbol/bigint/Error), plus expand DOMRect-like objects whose fields
946
+ // are non-enumerable. Kept as a standalone function so it stays editable/lintable.
947
+ function piEvalStringify(v) {
948
+ if (v === undefined) return { kind: "undefined" };
949
+ if (typeof v === "function") return { kind: "function", source: v.toString().slice(0, 500) };
950
+ if (typeof v === "symbol") return { kind: "symbol", description: v.description };
951
+ if (typeof v === "bigint") return { kind: "bigint", value: v.toString() };
952
+ if (v instanceof Error) return { kind: "error", name: v.name, message: v.message, stack: v.stack };
953
+ // DOMRect/DOMRectReadOnly (and getBoundingClientRect results) have non-enumerable
954
+ // properties, so JSON.stringify yields `{}`. Expand the fields explicitly.
955
+ if ((typeof DOMRectReadOnly !== "undefined" && v instanceof DOMRectReadOnly) ||
956
+ (typeof DOMRect !== "undefined" && v instanceof DOMRect) ||
957
+ (v && typeof v === "object" && typeof v.toJSON === "function" &&
958
+ typeof v.width === "number" && typeof v.height === "number" && typeof v.top === "number")) {
959
+ return { x: v.x, y: v.y, width: v.width, height: v.height, top: v.top, right: v.right, bottom: v.bottom, left: v.left };
960
+ }
961
+ return v;
962
+ }
963
+
964
+ // Dedicated executor for page.evaluate. Uses CDP Runtime.evaluate (via cdpEval) which is not
965
+ // subject to the page's CSP, fixing `chrome_evaluate` silently returning null / failing on
966
+ // pages that ship `script-src 'self'` without `'unsafe-eval'` (which blocks `eval`/`new Function`).
885
967
  async function evaluateInTab(params) {
886
968
  const tab = await getTabByParams(params);
887
969
  if (params.foreground) await bringToFront(tab);
888
970
  const expression = String(params.expression ?? "");
889
- const awaitPromise = params.awaitPromise !== false;
890
- const results = await chrome.scripting.executeScript({
891
- target: { tabId: tab.id },
892
- world: "MAIN",
893
- func: async (expression, awaitPromise) => {
894
- const stringify = (v) => {
895
- if (v === undefined) return { kind: "undefined" };
896
- if (typeof v === "function") return { kind: "function", source: v.toString().slice(0, 500) };
897
- if (typeof v === "symbol") return { kind: "symbol", description: v.description };
898
- if (typeof v === "bigint") return { kind: "bigint", value: v.toString() };
899
- if (v instanceof Error) return { kind: "error", name: v.name, message: v.message, stack: v.stack };
900
- // DOMRect/DOMRectReadOnly (and getBoundingClientRect results) have non-enumerable
901
- // properties, so JSON.stringify yields `{}`. Expand the fields explicitly.
902
- if ((typeof DOMRectReadOnly !== "undefined" && v instanceof DOMRectReadOnly) ||
903
- (typeof DOMRect !== "undefined" && v instanceof DOMRect) ||
904
- (v && typeof v === "object" && typeof v.toJSON === "function" &&
905
- typeof v.width === "number" && typeof v.height === "number" && typeof v.top === "number")) {
906
- return { x: v.x, y: v.y, width: v.width, height: v.height, top: v.top, right: v.right, bottom: v.bottom, left: v.left };
907
- }
908
- return v;
909
- };
910
- // Compile via the Function constructor. We try expression form first so callers can pass
911
- // `1+1` or `document.title` without a `return`; if that's a SyntaxError we retry with the
912
- // statement form so callers can use multi-statement bodies (loops, var decls, etc).
913
- const compile = (src) => {
914
- try {
915
- return { fn: new Function(`return (async () => (${src}))();`), mode: "expression" };
916
- } catch (e1) {
917
- if (e1 && e1.name === "SyntaxError") {
918
- try {
919
- return { fn: new Function(`return (async () => { ${src} })();`), mode: "statement" };
920
- } catch (e2) {
921
- throw e2;
922
- }
923
- }
924
- throw e1;
925
- }
926
- };
927
- try {
928
- const { fn } = compile(expression);
929
- const value = await fn.call(globalThis);
930
- const resolved = awaitPromise && value && typeof value.then === "function" ? await value : value;
931
- return { ok: true, value: stringify(resolved) };
932
- } catch (error) {
933
- return { ok: false, error: error?.stack || error?.message || String(error) };
934
- }
935
- },
936
- args: [expression, awaitPromise],
937
- });
938
- const first = results?.[0];
939
- if (first?.error) {
940
- const message = typeof first.error === "string" ? first.error : (first.error.message || JSON.stringify(first.error));
941
- throw new Error(`chrome_evaluate failed: ${message}`);
942
- }
943
- const envelope = first?.result;
944
- if (!envelope) throw new Error("chrome_evaluate returned no envelope from MAIN world");
945
- if (envelope.ok === false) throw new Error(envelope.error || "chrome_evaluate failed");
946
- const v = envelope.value;
947
- // Unwrap special markers from MAIN world
971
+ const stringifySrc = `(${piEvalStringify.toString()})`;
972
+ // Wrap the user expression so the result is run through piEvalStringify in-page before it
973
+ // crosses the returnByValue boundary. Try expression form first (so `1+1` / `document.title`
974
+ // work without `return`); on a SyntaxError fall back to statement form for multi-statement
975
+ // bodies (loops, var decls, etc), matching the previous new Function() two-form behavior.
976
+ const buildWrapper = (form) => `(async () => { const __s=${stringifySrc}; const __v = await ${form}; return __s(__v); })()`;
977
+ const exprForm = `(async () => (${expression}))()`;
978
+ const stmtForm = `(async () => { ${expression} })()`;
979
+
980
+ let res = await cdpEval(tab.id, buildWrapper(exprForm));
981
+ if (res.exceptionDetails && cdpIsSyntaxError(res.exceptionDetails)) {
982
+ res = await cdpEval(tab.id, buildWrapper(stmtForm));
983
+ }
984
+ if (res.exceptionDetails) {
985
+ throw new Error(`chrome_evaluate failed: ${cdpExceptionText(res.exceptionDetails) || "evaluation failed"}`);
986
+ }
987
+ const result = res.result;
988
+ if (!result || result.type === "undefined") return undefined;
989
+ const v = result.value;
990
+ // Unwrap special markers produced by piEvalStringify.
948
991
  if (v && typeof v === "object" && !Array.isArray(v)) {
949
992
  if (v.kind === "undefined") return undefined;
950
993
  if (v.kind === "function") return `[Function: ${v.source}]`;
@@ -964,29 +1007,23 @@ async function withOptionalSnapshot(params, actionFn) {
964
1007
  return result;
965
1008
  }
966
1009
 
967
- // One-shot init script registry, scoped per tab. The script source is injected at
968
- // document_start of the next committed navigation in that tab, in MAIN world, then cleared.
969
- const initScriptIds = new Map();
1010
+ // One-shot init script registry, scoped per tab. The source is registered with CDP
1011
+ // Page.addScriptToEvaluateOnNewDocument, which runs it at document_start in the page's MAIN
1012
+ // world and is NOT subject to page CSP (the old func:(code)=>new Function(code) path was
1013
+ // blocked by `script-src 'self'`). page.navigate registers before the nav and unregisters
1014
+ // after load, so only the intended navigation receives the script.
1015
+ const initScriptIds = new Map(); // tabId -> CDP script identifier
970
1016
  async function registerInitScript(tabId, source) {
971
- initScriptIds.set(tabId, source);
1017
+ await attachDebugger(tabId);
1018
+ await cdp(tabId, "Page.enable", {}).catch(() => undefined);
1019
+ const result = await cdp(tabId, "Page.addScriptToEvaluateOnNewDocument", { source });
1020
+ if (result && result.identifier !== undefined) initScriptIds.set(tabId, result.identifier);
972
1021
  }
973
1022
  async function unregisterInitScript(tabId) {
1023
+ const identifier = initScriptIds.get(tabId);
1024
+ if (identifier === undefined) return;
974
1025
  initScriptIds.delete(tabId);
975
- }
976
-
977
- if (chrome.webNavigation && chrome.webNavigation.onCommitted) {
978
- chrome.webNavigation.onCommitted.addListener((details) => {
979
- if (details.frameId !== 0) return;
980
- const source = initScriptIds.get(details.tabId);
981
- if (!source) return;
982
- chrome.scripting.executeScript({
983
- target: { tabId: details.tabId, frameIds: [0] },
984
- world: "MAIN",
985
- injectImmediately: true,
986
- func: (code) => { try { new Function(code).call(globalThis); } catch (e) { console.error("[pi-chrome init script]", e); } },
987
- args: [source],
988
- }).catch(() => undefined);
989
- });
1026
+ await cdp(tabId, "Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => undefined);
990
1027
  }
991
1028
 
992
1029
  // Always inject early console/network capture at document_start on every navigation.
@@ -2076,20 +2113,6 @@ function getNetworkRequest(requestId) {
2076
2113
  return request;
2077
2114
  }
2078
2115
 
2079
- async function waitForPage(kind, value, timeoutMs, intervalMs) {
2080
- const started = Date.now();
2081
- while (Date.now() - started < timeoutMs) {
2082
- let ok = false;
2083
- if (kind === "selector") ok = Boolean(document.querySelector(value));
2084
- else {
2085
- try { ok = Boolean(new Function("return (" + value + ");").call(globalThis)); } catch { ok = false; }
2086
- }
2087
- if (ok) return { elapsedMs: Date.now() - started };
2088
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
2089
- }
2090
- throw new Error(`Timed out after ${timeoutMs}ms waiting for ${kind}: ${value}`);
2091
- }
2092
-
2093
2116
  function normalizeKey(key) {
2094
2117
  const table = {
2095
2118
  enter: "Enter",
@@ -665,7 +665,7 @@ Chrome control is available through the chrome_* tools via a companion Chrome ex
665
665
  Capability model (important):
666
666
  - Interactive controls (click/type/fill/key/hover/drag/scroll/tap) use Chrome's real input layer via chrome.debugger / CDP. Events satisfy normal user-activation gates.
667
667
  - Input bypasses page CSP because it is injected at browser input layer, not page JavaScript. Chrome may show the “Pi Chrome Connector started debugging this browser” banner while attached.
668
- - \`chrome_evaluate\` and \`chrome_snapshot\` run in MAIN world via the **Function constructor**, which requires \`'unsafe-eval'\` in the page CSP. Pages with strict CSP (e.g. github.com, many bank/SaaS apps) will throw \`EvalError: ... 'unsafe-eval' is not an allowed source of script\` and chrome_snapshot will return empty. On those pages, drive the page with \`chrome_screenshot\` + viewport-coordinate \`chrome_click\`/\`chrome_type\`/\`chrome_key\`. \`chrome_navigate\`, \`chrome_screenshot\`, \`chrome_tab\`, and Chrome input all keep working under any CSP.
668
+ - \`chrome_evaluate\` and \`chrome_snapshot\` run in MAIN world via **CDP \`Runtime.evaluate\`**, which is not subject to the page's Content-Security-Policy. They work even on strict-CSP pages (e.g. github.com, many bank/SaaS apps) that block \`'unsafe-eval'\`. \`chrome_navigate initScript\` likewise injects at document_start via CDP and bypasses CSP. \`chrome_screenshot\`, \`chrome_tab\`, and Chrome input also work under any CSP.
669
669
  - Input tools return structured details and support \`includeSnapshot=true\` on click/type/fill/key. Use the fresh snapshot to verify state instead of repeating blindly.
670
670
 
671
671
  Usage rules:
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "pi-chrome",
3
- "version": "0.15.28",
3
+ "version": "0.15.29",
4
4
  "scripts": {
5
+ "test": "node test-suite/unit/csp-eval.test.mjs",
5
6
  "version": "node scripts/sync-manifest-version.js",
6
7
  "prepublishOnly": "node scripts/sync-manifest-version.js"
7
8
  },
@@ -125,7 +125,7 @@ Each unit challenge has a `gate` field:
125
125
  - `dom-complexity` / `frames` — Shadow DOM and iframe targeting.
126
126
  - `files` — file attachment to `<input type=file>`.
127
127
  - `observability` — console/network capture tools.
128
- - `csp` — strict Content Security Policy fallback where eval/snapshot may fail.
128
+ - `csp` — strict Content Security Policy: screenshot/coordinate fallback (39) and the CDP eval/snapshot bypass that works under `script-src 'self'` without `unsafe-eval` (42).
129
129
  - `lazy-loading` — dynamic DOM readiness and wait behavior.
130
130
  - `fingerprint` — environment and stack fingerprint probes.
131
131
  - `agent-safety` — hidden honeypots and safe target selection.
@@ -175,6 +175,7 @@ The dashboard renders this from `manifest.json`. In brief:
175
175
  39. strict CSP screenshot/coordinate fallback
176
176
  40. dynamic wait/readiness
177
177
  41. explicit tab lifecycle
178
+ 42. strict CSP eval/snapshot via CDP (regression guard for the CSP bypass)
178
179
 
179
180
  ## Design notes
180
181
 
@@ -0,0 +1,16 @@
1
+ <!doctype html>
2
+ <meta charset="utf-8">
3
+ <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'">
4
+ <title>42 strict CSP evaluate/snapshot</title>
5
+ <link rel="stylesheet" href="../_style.css">
6
+ <script src="../_lib.js"></script>
7
+ <body>
8
+ <main>
9
+ <p>Goal: this page ships a strict CSP (<code>script-src 'self'</code>, no <code>unsafe-eval</code>), which blocks <code>eval</code>/<code>new Function</code>. <code>chrome_evaluate</code> and <code>chrome_snapshot</code> must still work because they run through CDP, which is not subject to page CSP.</p>
10
+ <p id="hint">A secret token is exposed only at <code>window.__cspToken</code> — it is never written into the DOM. Use <code>chrome_evaluate</code> to read it, type it into the field (snapshot/uid to find the field), then click Verify.</p>
11
+ <label for="tokenInput">Token:</label>
12
+ <input id="tokenInput" type="text" autocomplete="off" aria-label="csp token">
13
+ <button id="verify" aria-label="verify token">Verify</button>
14
+ </main>
15
+ <script src="42-strict-csp-evaluate.js"></script>
16
+ </body>
@@ -0,0 +1,21 @@
1
+ Challenge.init({
2
+ id: "strict-csp-evaluate",
3
+ instructions: "under strict CSP: read window.__cspToken via chrome_evaluate, type it into the field, click Verify",
4
+ });
5
+
6
+ // Secret available only via JS evaluation. It is intentionally NOT rendered into the DOM and
7
+ // is defined non-enumerable, so the only way to obtain it is to evaluate window.__cspToken in
8
+ // the page (which proves chrome_evaluate works despite script-src 'self' blocking eval).
9
+ const token = "csp-" + Math.random().toString(36).slice(2, 10);
10
+ Object.defineProperty(window, "__cspToken", { value: token, enumerable: false, configurable: false, writable: false });
11
+
12
+ document.getElementById("verify").addEventListener("click", (e) => {
13
+ const bad = [];
14
+ if (!e.isTrusted) bad.push("verify click isTrusted=false (use trusted/CDP input)");
15
+ const val = (document.getElementById("tokenInput").value || "").trim();
16
+ if (val !== token) {
17
+ bad.push(`token mismatch: got "${val}" expected "${token}" — chrome_evaluate must read window.__cspToken under strict CSP`);
18
+ }
19
+ if (bad.length) Challenge.fail(...bad);
20
+ else Challenge.pass("strict CSP: chrome_evaluate read the hidden token via CDP and trusted input submitted it");
21
+ });
@@ -1510,7 +1510,7 @@
1510
1510
  "strict-csp"
1511
1511
  ],
1512
1512
  "notes": [
1513
- "chrome_snapshot/chrome_evaluate may fail on this page because script-src omits unsafe-eval; this is intentional. Use screenshot plus viewport coordinates, then read verdict from dashboard/localStorage after leaving CSP page."
1513
+ "This challenge exercises the pure screenshot + viewport-coordinate path (no snapshot/evaluate needed). Note: as of the CDP CSP bypass, chrome_snapshot/chrome_evaluate DO work here even though script-src omits unsafe-eval (see challenge 42 strict-csp-evaluate). Read verdict from dashboard/localStorage after leaving the CSP page."
1514
1514
  ],
1515
1515
  "manualBaseline": "unverified",
1516
1516
  "gradeSource": "page"
@@ -1626,5 +1626,56 @@
1626
1626
  ],
1627
1627
  "manualBaseline": "unverified",
1628
1628
  "gradeSource": "page"
1629
+ },
1630
+ {
1631
+ "id": "strict-csp-evaluate",
1632
+ "file": "challenges/42-strict-csp-evaluate.html",
1633
+ "category": "csp",
1634
+ "difficulty": "L2",
1635
+ "gate": "core",
1636
+ "goal": "On a page with strict CSP (script-src 'self', no unsafe-eval), use chrome_evaluate + chrome_snapshot via CDP to read a JS-only secret (window.__cspToken), then submit it with trusted input.",
1637
+ "expected": {
1638
+ "synthetic": "FAIL",
1639
+ "trusted": "PASS",
1640
+ "manual": "PASS"
1641
+ },
1642
+ "recipe": [
1643
+ {
1644
+ "tool": "chrome_evaluate",
1645
+ "params": {
1646
+ "expression": "window.__cspToken"
1647
+ }
1648
+ },
1649
+ {
1650
+ "tool": "chrome_fill",
1651
+ "params": {
1652
+ "selector": "#tokenInput",
1653
+ "value": "$RESULT_OF_chrome_evaluate",
1654
+ "trusted": true
1655
+ }
1656
+ },
1657
+ {
1658
+ "tool": "chrome_click",
1659
+ "params": {
1660
+ "selector": "#verify",
1661
+ "trusted": true
1662
+ }
1663
+ }
1664
+ ],
1665
+ "requires": {
1666
+ "cdp": true
1667
+ },
1668
+ "tags": [
1669
+ "csp",
1670
+ "evaluate",
1671
+ "snapshot",
1672
+ "strict-csp",
1673
+ "cdp-bypass"
1674
+ ],
1675
+ "notes": [
1676
+ "Regression guard for the CDP CSP bypass: chrome_evaluate and chrome_snapshot must work even though script-src omits unsafe-eval (eval/new Function are blocked). The token is non-enumerable and never in the DOM, so it can only be obtained via chrome_evaluate. Runner must substitute the evaluate result into the fill value. synthetic FAIL is due to the trusted-click gate, not eval availability."
1677
+ ],
1678
+ "manualBaseline": "unverified",
1679
+ "gradeSource": "page"
1629
1680
  }
1630
1681
  ]
@@ -0,0 +1,171 @@
1
+ // Unit harness for the CSP-bypass layer in service_worker.js.
2
+ //
3
+ // The real CSP bypass (CDP Runtime.evaluate not being subject to page CSP) can only be
4
+ // proven in a browser — see challenge 39-strict-csp-fallback. These tests instead validate
5
+ // the JS *logic* of the refactor that the bypass depends on:
6
+ // - evaluateInTab: wrapper-string construction, expression/statement fallback, value
7
+ // marker round-trip (undefined/function/symbol/bigint/Error/DOMRect), error propagation.
8
+ // - executeInTab: 2-phase define-then-invoke, envelope unwrap, error propagation, and that
9
+ // all real HELPER_FUNCS serialize+assign without a parse error.
10
+ // - page.waitFor: service-worker-side polling via evaluateInTab (selector + expression).
11
+ //
12
+ // We load the worker into a vm sandbox with mocked chrome.* APIs, then replace `cdp` with a
13
+ // shim that evaluates the expression in a separate "page world" vm context (simulating CDP
14
+ // Runtime.evaluate returnByValue). No browser, no network, no deps.
15
+
16
+ import vm from "node:vm";
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+
21
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
+ const workerPath = path.resolve(__dirname, "../../extensions/chrome-profile-bridge/browser-extension/service_worker.js");
23
+ const src = fs.readFileSync(workerPath, "utf8");
24
+
25
+ let failures = 0;
26
+ let passes = 0;
27
+ function ok(cond, msg) {
28
+ if (cond) { passes++; }
29
+ else { failures++; console.error(` ✗ ${msg}`); }
30
+ }
31
+ async function throwsWith(fn, re, msg) {
32
+ try { await fn(); ok(false, `${msg} (expected throw)`); }
33
+ catch (e) { ok(re.test(String(e.message || e)), `${msg} (got: ${e.message})`); }
34
+ }
35
+
36
+ // ---- page world: simulates the page's MAIN world for Runtime.evaluate ----
37
+ const pageGlobals = {
38
+ console, JSON, Date, Math, Promise, Object, Array, String, Number, Boolean,
39
+ Error, TypeError, SyntaxError, RangeError, BigInt, Symbol, structuredClone,
40
+ setTimeout, parseInt, parseFloat, isNaN,
41
+ document: {
42
+ title: "page title",
43
+ _present: new Set(),
44
+ querySelector(sel) { return this._present.has(sel) ? { sel } : null; },
45
+ },
46
+ };
47
+ pageGlobals.window = pageGlobals;
48
+ pageGlobals.globalThis = pageGlobals;
49
+ const pageWorld = vm.createContext(pageGlobals);
50
+
51
+ // Simulate CDP Runtime.evaluate returnByValue serialization.
52
+ function toCdpResult(v) {
53
+ if (v === undefined) return { result: { type: "undefined" } };
54
+ if (v === null) return { result: { type: "object", subtype: "null", value: null } };
55
+ const t = typeof v;
56
+ if (t === "number" || t === "string" || t === "boolean")
57
+ return { result: { type: t, value: v } };
58
+ // object/array: returnByValue deep-clones JSON-able structures
59
+ return { result: { type: "object", value: JSON.parse(JSON.stringify(v)) } };
60
+ }
61
+
62
+ // ---- worker sandbox ----
63
+ const noop = () => {};
64
+ const listener = { addListener: noop, removeListener: noop };
65
+ const sandbox = {
66
+ console, JSON, Date, Math, Promise, Array, Object, String, Number, Boolean,
67
+ Error, TypeError, Map, Set, BigInt, Symbol, structuredClone,
68
+ setTimeout, clearTimeout,
69
+ setInterval: () => 0,
70
+ clearInterval: noop,
71
+ fetch: async () => { throw new Error("no network in unit test"); },
72
+ navigator: { userAgent: "unit-test" },
73
+ WebSocket: function () {},
74
+ chrome: {
75
+ runtime: { id: "unittestextension", getManifest: () => ({ version: "0.0.0" }), onInstalled: listener, onStartup: listener, lastError: null },
76
+ alarms: { onAlarm: listener, create: noop, clear: noop, clearAll: noop },
77
+ action: { onClicked: listener },
78
+ debugger: { sendCommand: noop, attach: async () => {}, detach: async () => {}, getTargets: (cb) => cb([]) },
79
+ scripting: { executeScript: async () => [{ result: undefined }] },
80
+ tabs: { query: async () => [], get: async () => ({}), create: async () => ({}), update: async () => ({}), remove: async () => {} },
81
+ windows: { update: async () => {} },
82
+ webNavigation: { onCommitted: listener },
83
+ },
84
+ };
85
+ sandbox.globalThis = sandbox;
86
+ sandbox.self = sandbox;
87
+ vm.createContext(sandbox);
88
+ vm.runInContext(src, sandbox);
89
+
90
+ // ---- override the page-touching primitives with the page-world shim ----
91
+ sandbox.attachDebugger = async () => ({});
92
+ sandbox.bringToFront = async () => {};
93
+ sandbox.getTabByParams = async (p) => ({ id: (p && p.targetId) || 1, windowId: 1 });
94
+ sandbox.cdp = async (_tabId, method, params) => {
95
+ if (method !== "Runtime.evaluate") return {};
96
+ try {
97
+ const value = await vm.runInContext(params.expression, pageWorld);
98
+ return toCdpResult(value);
99
+ } catch (e) {
100
+ return { exceptionDetails: { exception: { className: e.name, description: String(e.stack || e.message) }, text: "Uncaught " + String(e) } };
101
+ }
102
+ };
103
+ // Phase-2 of executeInTab: run the injected wrapper func against the page world,
104
+ // where Phase-1 (via cdp shim above) already defined window.__piAction + helpers.
105
+ sandbox.chrome.scripting.executeScript = async ({ func, args }) => {
106
+ const fn = vm.runInContext("(" + func.toString() + ")", pageWorld);
107
+ const result = await fn(...(args || []));
108
+ return [{ result }];
109
+ };
110
+
111
+ const { evaluateInTab, executeInTab, dispatch } = sandbox;
112
+
113
+ async function run() {
114
+ // ===== evaluateInTab: primitives & objects =====
115
+ ok((await evaluateInTab({ expression: "2 + 2" })) === 4, "evaluate: arithmetic expression");
116
+ ok((await evaluateInTab({ expression: "document.title" })) === "page title", "evaluate: expression without return");
117
+ ok((await evaluateInTab({ expression: "'a' + 'b'" })) === "ab", "evaluate: string concat");
118
+ const obj = await evaluateInTab({ expression: "({a:1, b:[2,3]})" });
119
+ ok(obj && obj.a === 1 && obj.b[1] === 3, "evaluate: object literal round-trips");
120
+
121
+ // ===== value markers =====
122
+ ok((await evaluateInTab({ expression: "void 0" })) === undefined, "evaluate: undefined marker -> undefined");
123
+ ok((await evaluateInTab({ expression: "10n" })) === "10", "evaluate: bigint marker -> string");
124
+ ok(/^\[Function:/.test(await evaluateInTab({ expression: "(function foo(){})" })), "evaluate: function marker");
125
+ ok((await evaluateInTab({ expression: "Promise.resolve(42)" })) === 42, "evaluate: promise is awaited");
126
+
127
+ // DOMRect-like (toJSON + width/height/top) is expanded, not flattened to {}
128
+ const rect = await evaluateInTab({ expression: "({ x:1,y:2,width:3,height:4,top:2,right:4,bottom:6,left:1, toJSON(){return {}} })" });
129
+ ok(rect && rect.width === 3 && rect.bottom === 6, "evaluate: DOMRect-like expanded");
130
+
131
+ // ===== statement-form fallback (expression form is a SyntaxError) =====
132
+ // `let x=...; x` is not a valid expression, so the wrapper must retry as a statement body.
133
+ ok((await evaluateInTab({ expression: "let x = 5; x" })) === undefined, "evaluate: statement form falls back (no return -> undefined)");
134
+ ok((await evaluateInTab({ expression: "let y = 7; return y" })) === 7, "evaluate: statement form with explicit return");
135
+
136
+ // ===== error propagation =====
137
+ await throwsWith(() => evaluateInTab({ expression: "throw new Error('boom')" }), /chrome_evaluate failed[\s\S]*boom/, "evaluate: runtime error propagates");
138
+
139
+ // ===== executeInTab: 2-phase define + invoke =====
140
+ // Real HELPER_FUNCS get serialized + assigned in Phase 1; a parse error there would throw here.
141
+ const sum = await executeInTab({ targetId: 1 }, function add(a, b) { return a + b; }, [3, 4]);
142
+ ok(sum === 7, "executeInTab: action runs with args after helper injection");
143
+
144
+ const asyncResult = await executeInTab({ targetId: 1 }, async function asyncEcho(v) { return v * 2; }, [21]);
145
+ ok(asyncResult === 42, "executeInTab: async action awaited");
146
+
147
+ await throwsWith(
148
+ () => executeInTab({ targetId: 1 }, function boom() { throw new Error("action failed"); }, []),
149
+ /action failed/,
150
+ "executeInTab: thrown action error propagates via envelope",
151
+ );
152
+
153
+ // ===== page.waitFor (service-worker-side polling) =====
154
+ pageGlobals.document._present.add("#ready");
155
+ const wf = await dispatch("page.waitFor", { targetId: 1, kind: "selector", value: "#ready", timeoutMs: 1000, intervalMs: 20 });
156
+ ok(wf && typeof wf.elapsedMs === "number", "waitFor: selector present resolves");
157
+
158
+ const wfExpr = await dispatch("page.waitFor", { targetId: 1, kind: "expression", value: "1 === 1", timeoutMs: 1000, intervalMs: 20 });
159
+ ok(wfExpr && typeof wfExpr.elapsedMs === "number", "waitFor: truthy expression resolves");
160
+
161
+ await throwsWith(
162
+ () => dispatch("page.waitFor", { targetId: 1, kind: "selector", value: "#never", timeoutMs: 120, intervalMs: 30 }),
163
+ /Timed out after 120ms/,
164
+ "waitFor: missing selector times out",
165
+ );
166
+
167
+ console.log(`\n${passes} passed, ${failures} failed`);
168
+ if (failures) process.exit(1);
169
+ }
170
+
171
+ run().catch((e) => { console.error(e); process.exit(1); });