@proagentstore/cli 0.4.72 → 0.4.74

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.
@@ -1,6 +1,8 @@
1
1
  import { LOCAL_BROWSER_SOURCE_FAILURE_REASONS, redactDetail } from "./contract.js";
2
2
  /** The browser tools a research run may use. Everything else from the browser is not even listed. */
3
3
  const READ_TOOLS = new Set(["browser_navigate", "browser_navigate_back", "browser_snapshot", "browser_wait_for", "browser_click"]);
4
+ /** The research tools the bridge adds; the CLI records its results through these. */
5
+ const RESEARCH_TOOL_NAMES = ["record_finding", "report_source_failure", "finish_research"];
4
6
  /** Refused BY NAME with the reason, so a CLI that guesses one is told why rather than "unknown tool". */
5
7
  const WRITE_TOOLS = new Set([
6
8
  "browser_type",
@@ -18,6 +20,11 @@ const WRITE_TOOLS = new Set([
18
20
  /** Roles a click may target in research mode: moving between pages, never acting on one. */
19
21
  const CLICKABLE_ROLES = new Set(["link", "tab", "menuitem", "treeitem", "option"]);
20
22
  const PAGINATION_NAME = /^(next|previous|prev|more|load more|show more|see more|view more|older|newer|page \d+|\d+|›|»|‹|«)( page| results| jobs)?$/i;
23
+ /**
24
+ * Every tool name the bridge can ever list (#952) — the exact set a CLI may be told to trust
25
+ * without asking. Write tools are not in it, so trusting this set cannot trust a write.
26
+ */
27
+ export const BRIDGE_TOOL_NAMES = [...READ_TOOLS, ...RESEARCH_TOOL_NAMES];
21
28
  const RESEARCH_TOOLS = [
22
29
  {
23
30
  name: "record_finding",
@@ -157,22 +164,35 @@ export class BrowserBridge {
157
164
  const last = [...this.visited].pop();
158
165
  return last ? { domain: last } : {};
159
166
  }
160
- refuse(tool, reason) {
161
- this.host.emit({ type: "policy.decision", detail: { tool, decision: "refused", reason } });
167
+ /** Refuse, on the trace — naming the site and the owner's decision behind it when there is one. */
168
+ refuse(tool, reason, host) {
169
+ const consentId = host ? this.host.consentIdFor(host) : undefined;
170
+ this.host.emit({ type: "policy.decision", ...(host ? { domain: host } : {}), ...(consentId ? { consentId } : {}), detail: { tool, decision: "refused", reason } });
162
171
  return text(reason, true);
163
172
  }
173
+ /** Hosts already admitted on this run — a site is recorded as allowed once, not on every page. */
174
+ admitted = new Set();
175
+ allowed(host, basis) {
176
+ if (this.admitted.has(host))
177
+ return;
178
+ this.admitted.add(host);
179
+ const consentId = basis === "consent" ? this.host.consentIdFor(host) : undefined;
180
+ this.host.emit({ type: "policy.decision", domain: host, ...(consentId ? { consentId } : {}), detail: { decision: "allowed", basis } });
181
+ }
164
182
  /** May the run be on this host — asking the owner, and waiting, when it is a new one. */
165
183
  async admit(host, url) {
166
184
  if (this.host.isDenied(host))
167
185
  return `${host} is on this run's deny list.`;
168
- if (this.host.isPermitted(host))
186
+ if (this.host.isPermitted(host)) {
187
+ this.allowed(host, this.host.consentIdFor(host) ? "consent" : "allow_list");
169
188
  return null;
189
+ }
170
190
  if (this.host.allowListOnly())
171
191
  return `${host} is not on this run's list of allowed sites.`;
172
192
  this.host.emit({ type: "consent.requested", url, domain: host, detail: { scope: "navigate" } });
173
193
  const outcome = await this.host.pause("consent_required", { domain: host });
174
194
  if (outcome === "resumed" && this.host.isPermitted(host) && !this.host.isDenied(host)) {
175
- this.host.emit({ type: "policy.decision", domain: host, detail: { decision: "consented" } });
195
+ this.allowed(host, "consent");
176
196
  return null;
177
197
  }
178
198
  return `The owner did not allow ${host}. Skip it and record it with report_source_failure (access_denied).`;
@@ -185,7 +205,7 @@ export class BrowserBridge {
185
205
  return this.refuse("browser_navigate", `The run's limit of ${this.host.limits.maxPages} pages is reached. Call finish_research now.`);
186
206
  const refusal = await this.admit(host, url);
187
207
  if (refusal)
188
- return this.refuse("browser_navigate", refusal);
208
+ return this.refuse("browser_navigate", refusal, host);
189
209
  const res = await this.browser.callTool("browser_navigate", { url });
190
210
  if (res.isError)
191
211
  return { content: [{ type: "text", text: textOf(res) }], isError: true };
@@ -47,16 +47,54 @@ export const LOCAL_BROWSER_EVENT_TYPES = [
47
47
  "run.resumed",
48
48
  "note",
49
49
  ];
50
- export const LOCAL_BROWSER_PLATFORM_EVENT_TYPES = ["run.requested", "runner.dispatched", "run.ended"];
50
+ export const LOCAL_BROWSER_PLATFORM_EVENT_TYPES = ["run.requested", "runner.dispatched", "run.ended", "review.decision"];
51
51
  export const LOCAL_BROWSER_ENGINE_AUTHS = ["subscription", "machine-login", "api-key", "missing_login", "unknown"];
52
52
  export const LOCAL_BROWSER_SOURCE_FAILURE_REASONS = ["login_required", "captcha", "paywall", "access_denied", "robots", "timeout", "error"];
53
53
  export const LOCAL_BROWSER_CAPS = { findings: 200, sourceFailures: 200, text: 4000, fieldCount: 40 };
54
54
  const SENSITIVE_KEY = /cookie|password|passwd|secret|token|authorization|api[_-]?key|otp|form[_-]?values?|credential/i;
55
+ /** An env var NAME whose value is a credential. */
56
+ const SECRET_ENV_NAME = /KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|COOKIE|SESSION|AUTH/i;
57
+ /** Credential-shaped text, wherever it appears — the patterns a CLI or a page tends to print. */
58
+ const SECRET_PATTERNS = [
59
+ // NAME=value for a credential-named variable: keep the name, drop the value.
60
+ [/\b([A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL)[A-Z0-9_]*)\s*[=:]\s*["']?[^\s"']+["']?/g, "$1=[REDACTED]"],
61
+ [/\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{10,}/gi, "$1 [REDACTED]"],
62
+ [/\bsk-[A-Za-z0-9_-]{20,}/g, "[REDACTED]"],
63
+ [/\b(?:ghp|gho|ghs|ghu|github_pat|xox[abpr])_[A-Za-z0-9_]{20,}/g, "[REDACTED]"],
64
+ [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, "[REDACTED]"],
65
+ // Base64-ish runs over 40 characters: keys, tokens, session blobs.
66
+ [/[A-Za-z0-9+/_-]{41,}={0,2}/g, "[REDACTED]"],
67
+ ];
68
+ /**
69
+ * The values of credential-named variables in an environment, longest first — what `redactText`
70
+ * must never let through even when the value has no recognisable shape.
71
+ */
72
+ export function secretEnvValues(env) {
73
+ return Object.entries(env)
74
+ .filter(([k, v]) => SECRET_ENV_NAME.test(k) && typeof v === "string" && v.trim().length >= 8)
75
+ .map(([, v]) => v.trim())
76
+ .sort((a, b) => b.length - a.length);
77
+ }
78
+ /**
79
+ * Remove credentials from free text before it is stored (#947): first every known secret value,
80
+ * verbatim, then anything credential-shaped. Used on CLI output that becomes an error, on result
81
+ * text, and on every string in a trace detail.
82
+ */
83
+ export function redactText(text, secrets = []) {
84
+ let out = text;
85
+ for (const s of secrets)
86
+ if (s.length >= 8)
87
+ out = out.split(s).join("[REDACTED]");
88
+ for (const [re, sub] of SECRET_PATTERNS)
89
+ out = out.replace(re, sub);
90
+ return out;
91
+ }
55
92
  /**
56
- * Drop sensitive keys and bound sizes. A trace is for a supervisor to read, so nothing in it may
57
- * carry a cookie, a password, a form value or a key — whatever the runner sent.
93
+ * Drop sensitive keys, redact credential-shaped values and bound sizes. A trace is for a supervisor
94
+ * to read, so nothing in it may carry a cookie, a password, a form value or a key — whatever the
95
+ * runner sent.
58
96
  */
59
- export function redactDetail(detail, depth = 0) {
97
+ export function redactDetail(detail, depth = 0, secrets = []) {
60
98
  if (!detail || typeof detail !== "object" || Array.isArray(detail) || depth > 3)
61
99
  return undefined;
62
100
  const out = {};
@@ -64,13 +102,13 @@ export function redactDetail(detail, depth = 0) {
64
102
  if (SENSITIVE_KEY.test(k))
65
103
  continue;
66
104
  if (typeof v === "string")
67
- out[k] = v.slice(0, 1000);
105
+ out[k] = redactText(v.slice(0, 1000), secrets);
68
106
  else if (typeof v === "number" || typeof v === "boolean" || v === null)
69
107
  out[k] = v;
70
108
  else if (Array.isArray(v))
71
- out[k] = v.slice(0, 20).map((x) => (typeof x === "string" ? x.slice(0, 300) : typeof x === "number" || typeof x === "boolean" ? x : null));
109
+ out[k] = v.slice(0, 20).map((x) => (typeof x === "string" ? redactText(x.slice(0, 300), secrets) : typeof x === "number" || typeof x === "boolean" ? x : null));
72
110
  else if (typeof v === "object") {
73
- const nested = redactDetail(v, depth + 1);
111
+ const nested = redactDetail(v, depth + 1, secrets);
74
112
  if (nested)
75
113
  out[k] = nested;
76
114
  }
@@ -167,8 +205,10 @@ export function parseLocalBrowserResult(raw) {
167
205
  const detail = str(r.detail, 1000);
168
206
  sourceFailures.push(detail ? { url, reason, detail } : { url, reason });
169
207
  }
170
- const out = { runId, outcome, findings, sourceFailures, summary: str(o.summary, LOCAL_BROWSER_CAPS.text) ?? "", traceId, engineAuth };
171
- const error = str(o.error, 1000);
208
+ const out = { runId, outcome, findings, sourceFailures, summary: redactText(str(o.summary, LOCAL_BROWSER_CAPS.text) ?? ""), traceId, engineAuth };
209
+ // Redacted again here, whatever the runner did: an error is CLI output, and CLIs print keys.
210
+ const rawError = str(o.error, 1000);
211
+ const error = rawError === null ? null : redactText(rawError);
172
212
  if (outcome === "failed")
173
213
  out.error = error ?? "The run failed without a reason.";
174
214
  return { result: out };
@@ -12,6 +12,13 @@
12
12
  * - Codex: `--sandbox read-only` (its shell cannot write, and per Codex's sandbox design has no
13
13
  * network), `--ignore-user-config` (no MCP servers or providers from the user's own config — auth
14
14
  * still comes from the login), web search switched off, and the bridge as its only MCP server.
15
+ * `codex exec` runs with approval policy `never`, so an MCP tool that needs approval is simply
16
+ * refused — a live run on 2026-10-07 opened no page for exactly that reason (#952). The bridge is
17
+ * therefore pre-approved, and ONLY it: `default_tools_approval_mode="approve"` on the
18
+ * `pags_browser` server, and `enabled_tools` pinned to the bridge's own tool names
19
+ * (`BRIDGE_TOOL_NAMES`). Nothing else is approved — the shell stays read-only with no network,
20
+ * there is no other server — and the bridge still enforces sites, consent, limits and the
21
+ * research-only tool set in the runner, whatever the CLI is allowed to call.
15
22
  * NOT VERIFIED END TO END on a live machine: that read-only shell commands get no network, and
16
23
  * which of the two web-search keys a given Codex version honours. #947 holds that test.
17
24
  *
@@ -21,6 +28,7 @@
21
28
  * turns a subscription run into per-token billing.
22
29
  */
23
30
  import { mergeEnv } from "../coding/engine-env.js";
31
+ import { BRIDGE_TOOL_NAMES } from "./bridge.js";
24
32
  import { resolveEngineAuth } from "../coding/engine-auth.js";
25
33
  /** The bridge's MCP server name — and so the prefix of its tools inside the CLI. */
26
34
  export const BRIDGE_SERVER_NAME = "pags_browser";
@@ -89,6 +97,11 @@ export function buildEngineSpec(input) {
89
97
  `${key}.env=${envTable}`,
90
98
  "-c",
91
99
  `${key}.tool_timeout_sec=${Math.ceil(input.toolTimeoutMs / 1000)}`,
100
+ // Pre-approve the bridge, and only its own tools (#952); see the note atop this file.
101
+ "-c",
102
+ `${key}.default_tools_approval_mode="approve"`,
103
+ "-c",
104
+ `${key}.enabled_tools=[${BRIDGE_TOOL_NAMES.map(toml).join(", ")}]`,
92
105
  // Two spellings across Codex versions; an unknown key is ignored, so both are set.
93
106
  "-c",
94
107
  'web_search="disabled"',
@@ -26,7 +26,7 @@ import { fileURLToPath } from "node:url";
26
26
  import { RunnerInputError } from "../errors.js";
27
27
  import { BrowserBridge, domainWithin } from "./bridge.js";
28
28
  import { BRIDGE_ENV } from "./bridge-stdio.js";
29
- import { LOCAL_BROWSER_AUTH_MODES, LOCAL_BROWSER_ENGINES, LOCAL_BROWSER_TASK_TYPE, redactDetail, } from "./contract.js";
29
+ import { LOCAL_BROWSER_AUTH_MODES, LOCAL_BROWSER_ENGINES, LOCAL_BROWSER_TASK_TYPE, redactDetail, redactText, secretEnvValues, } from "./contract.js";
30
30
  import { buildEngineSpec, finalText, missingLogin, observedEngineAuth, researchPrompt, signInHelp } from "./engine.js";
31
31
  const MAX_EVENTS = 2000;
32
32
  const OUTPUT_LINES = 400;
@@ -76,11 +76,28 @@ export function parseEnvelope(raw) {
76
76
  authMode: o.authMode,
77
77
  workspace: ws.kind === "path" ? { kind: "path", path: ws.path } : { kind: "scratch" },
78
78
  browserProfile: o.browserProfile,
79
- policy: { mode: "research_only", allowDomains: strList(p.allowDomains), denyDomains: strList(p.denyDomains), consentedDomains: strList(p.consentedDomains), profileConsented: p.profileConsented === true },
79
+ policy: {
80
+ mode: "research_only",
81
+ allowDomains: strList(p.allowDomains),
82
+ denyDomains: strList(p.denyDomains),
83
+ consentedDomains: strList(p.consentedDomains),
84
+ profileConsented: p.profileConsented === true,
85
+ consentIds: consentIdMap(p.consentIds),
86
+ },
80
87
  limits,
81
88
  resultSchema: { id: typeof schema.id === "string" ? schema.id : "findings", version: typeof schema.version === "number" ? schema.version : 1 },
82
89
  };
83
90
  }
91
+ /** Decision ids keyed by lowercase domain (or `*`), from an untrusted object. */
92
+ function consentIdMap(raw) {
93
+ const out = {};
94
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
95
+ for (const [k, v] of Object.entries(raw).slice(0, 200))
96
+ if (typeof v === "string" && SAFE_ID.test(v))
97
+ out[k.toLowerCase()] = v;
98
+ }
99
+ return out;
100
+ }
84
101
  /**
85
102
  * A `~/…` workspace, resolved under the home directory — re-checked here against the real disk,
86
103
  * because the API could only check the string. A symlink out of home is refused like `..` is.
@@ -142,6 +159,8 @@ export class LocalBrowserRuntime {
142
159
  consented: new Set(envelope.policy.consentedDomains),
143
160
  deny: new Set(envelope.policy.denyDomains),
144
161
  profileConsented: envelope.policy.profileConsented,
162
+ consentIds: new Map(Object.entries(envelope.policy.consentIds ?? {})),
163
+ secrets: secretEnvValues(process.env),
145
164
  activeSince: this.now(),
146
165
  activeMs: 0,
147
166
  waiters: [],
@@ -165,10 +184,13 @@ export class LocalBrowserRuntime {
165
184
  if (outcome !== "resumed" || !run.profileConsented)
166
185
  throw new Error("The owner did not allow research in their signed-in browser profile.");
167
186
  }
187
+ if (e.browserProfile === "default")
188
+ this.emit(run, { type: "policy.decision", ...this.consentRef(run, "*"), detail: { scope: "signed_in_profile", decision: "consented" } });
168
189
  run.browser = await this.deps.browserFor(e.browserProfile, run.dir);
169
190
  run.bridge = new BrowserBridge(run.browser.tools, {
170
191
  emit: (ev) => this.emit(run, ev),
171
192
  pause: (reason, detail) => this.pause(run, reason, detail),
193
+ consentIdFor: (host) => this.consentRef(run, host).consentId,
172
194
  isDenied: (host) => [...run.deny].some((d) => domainWithin(host, d)),
173
195
  isPermitted: (host) => [...e.policy.allowDomains, ...run.consented].some((d) => domainWithin(host, d)),
174
196
  allowListOnly: () => e.policy.allowDomains.length > 0,
@@ -186,6 +208,8 @@ export class LocalBrowserRuntime {
186
208
  });
187
209
  if (spec.mcpConfig)
188
210
  writeFileSync(spec.mcpConfig.path, spec.mcpConfig.json, { mode: 0o600 });
211
+ // The env the engine actually gets — a key the owner chose to pass (api-key mode) included.
212
+ run.secrets = secretEnvValues({ ...process.env, ...spec.env });
189
213
  run.engineAuth = observedEngineAuth(e.engine, spec.env);
190
214
  this.emit(run, { type: "engine.auth_checked", detail: { engine: e.engine, authMode: e.authMode, engineAuth: run.engineAuth } });
191
215
  const child = this.spawn(spec.command, spec.args, { cwd: run.workDir, env: spec.env, stdio: ["ignore", "pipe", "pipe"] });
@@ -231,7 +255,7 @@ export class LocalBrowserRuntime {
231
255
  if (run.events.length >= MAX_EVENTS)
232
256
  return;
233
257
  const { detail: raw, ...rest } = ev;
234
- const detail = redactDetail(raw);
258
+ const detail = redactDetail(raw, 0, run.secrets);
235
259
  run.events.push({ ...rest, ...(detail && Object.keys(detail).length ? { detail } : {}), at: new Date(this.now()).toISOString(), seq: ++run.seq });
236
260
  }
237
261
  pause(run, reason, detail) {
@@ -262,6 +286,8 @@ export class LocalBrowserRuntime {
262
286
  const run = this.get(String(o.runId ?? ""));
263
287
  if (run.state === "ended")
264
288
  throw new RunnerInputError("The run has ended", 409);
289
+ for (const [d, id] of Object.entries(consentIdMap(o.consentIds)))
290
+ run.consentIds.set(d, id);
265
291
  for (const d of strList(o.consentedDomains))
266
292
  run.consented.add(d);
267
293
  for (const d of strList(o.denyDomains))
@@ -313,6 +339,16 @@ export class LocalBrowserRuntime {
313
339
  return run.bridge.callTool(o.name, (o.args && typeof o.args === "object" ? o.args : {}));
314
340
  throw new RunnerInputError("op must be list or call");
315
341
  }
342
+ /** The decision that covers this host (or `*`), as an event field — empty when none does. */
343
+ consentRef(run, host) {
344
+ if (host === "*")
345
+ return run.consentIds.has("*") ? { consentId: run.consentIds.get("*") } : {};
346
+ let best = null;
347
+ for (const [d, id] of run.consentIds)
348
+ if (d !== "*" && domainWithin(host, d) && (!best || d.length > best[0].length))
349
+ best = [d, id];
350
+ return best ? { consentId: best[1] } : {};
351
+ }
316
352
  get(runId) {
317
353
  const run = this.runs.get(runId);
318
354
  if (!run)
@@ -367,10 +403,11 @@ export class LocalBrowserRuntime {
367
403
  outcome: r.outcome,
368
404
  findings: run.bridge?.findings ?? [],
369
405
  sourceFailures: run.bridge?.sourceFailures ?? [],
370
- summary: (r.summary ?? "").slice(0, 4000),
406
+ summary: redactText((r.summary ?? "").slice(0, 4000), run.secrets),
371
407
  traceId: run.envelope.runId,
372
408
  engineAuth: r.engineAuth ?? run.engineAuth,
373
- ...(r.outcome === "failed" ? { error: (r.error ?? "The run failed without a reason.").slice(0, 1000) } : {}),
409
+ // CLI output becomes this text, and a CLI prints whatever it was given: redacted before it is kept.
410
+ ...(r.outcome === "failed" ? { error: redactText((r.error ?? "The run failed without a reason.").slice(0, 1000), run.secrets) } : {}),
374
411
  };
375
412
  if (run.child && run.child.exitCode === null)
376
413
  this.kill(run);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proagentstore/cli",
3
- "version": "0.4.72",
3
+ "version": "0.4.74",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",