@proagentstore/cli 0.4.72 → 0.4.73

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.
@@ -157,22 +157,35 @@ export class BrowserBridge {
157
157
  const last = [...this.visited].pop();
158
158
  return last ? { domain: last } : {};
159
159
  }
160
- refuse(tool, reason) {
161
- this.host.emit({ type: "policy.decision", detail: { tool, decision: "refused", reason } });
160
+ /** Refuse, on the trace — naming the site and the owner's decision behind it when there is one. */
161
+ refuse(tool, reason, host) {
162
+ const consentId = host ? this.host.consentIdFor(host) : undefined;
163
+ this.host.emit({ type: "policy.decision", ...(host ? { domain: host } : {}), ...(consentId ? { consentId } : {}), detail: { tool, decision: "refused", reason } });
162
164
  return text(reason, true);
163
165
  }
166
+ /** Hosts already admitted on this run — a site is recorded as allowed once, not on every page. */
167
+ admitted = new Set();
168
+ allowed(host, basis) {
169
+ if (this.admitted.has(host))
170
+ return;
171
+ this.admitted.add(host);
172
+ const consentId = basis === "consent" ? this.host.consentIdFor(host) : undefined;
173
+ this.host.emit({ type: "policy.decision", domain: host, ...(consentId ? { consentId } : {}), detail: { decision: "allowed", basis } });
174
+ }
164
175
  /** May the run be on this host — asking the owner, and waiting, when it is a new one. */
165
176
  async admit(host, url) {
166
177
  if (this.host.isDenied(host))
167
178
  return `${host} is on this run's deny list.`;
168
- if (this.host.isPermitted(host))
179
+ if (this.host.isPermitted(host)) {
180
+ this.allowed(host, this.host.consentIdFor(host) ? "consent" : "allow_list");
169
181
  return null;
182
+ }
170
183
  if (this.host.allowListOnly())
171
184
  return `${host} is not on this run's list of allowed sites.`;
172
185
  this.host.emit({ type: "consent.requested", url, domain: host, detail: { scope: "navigate" } });
173
186
  const outcome = await this.host.pause("consent_required", { domain: host });
174
187
  if (outcome === "resumed" && this.host.isPermitted(host) && !this.host.isDenied(host)) {
175
- this.host.emit({ type: "policy.decision", domain: host, detail: { decision: "consented" } });
188
+ this.allowed(host, "consent");
176
189
  return null;
177
190
  }
178
191
  return `The owner did not allow ${host}. Skip it and record it with report_source_failure (access_denied).`;
@@ -185,7 +198,7 @@ export class BrowserBridge {
185
198
  return this.refuse("browser_navigate", `The run's limit of ${this.host.limits.maxPages} pages is reached. Call finish_research now.`);
186
199
  const refusal = await this.admit(host, url);
187
200
  if (refusal)
188
- return this.refuse("browser_navigate", refusal);
201
+ return this.refuse("browser_navigate", refusal, host);
189
202
  const res = await this.browser.callTool("browser_navigate", { url });
190
203
  if (res.isError)
191
204
  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 };
@@ -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.73",
4
4
  "description": "CLI for creating, publishing, and running ProAgentStore agents",
5
5
  "license": "MIT",
6
6
  "type": "module",