cookbook-bridge 0.1.2 → 0.1.3

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/cookbook.mjs CHANGED
@@ -246,7 +246,7 @@ export async function fetchHands(cfg) {
246
246
  if (res.status === 404) return { supported: false, calls: [], grants: [] };
247
247
  if (!res.ok) throw new Error(`hands ${res.status}`);
248
248
  const j = await res.json().catch(() => ({}));
249
- return { supported: true, calls: j.calls ?? [], grants: j.grants ?? [] };
249
+ return { supported: true, calls: j.calls ?? [], grants: j.grants ?? [], awaiting: j.awaiting ?? [] };
250
250
  }
251
251
 
252
252
  /** Claim one call before running it. The server CASes on status, so two Bridges on
package/hands.mjs CHANGED
@@ -97,7 +97,7 @@ function collapseHome(text, home) {
97
97
  const h = String(home ?? "").replace(/\/+$/, "");
98
98
  if (!h || h.length < 4) return text;
99
99
  const esc = h.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
100
- return text.replace(new RegExp(esc, "g"), "~");
100
+ return text.replace(new RegExp(esc + "(?=/|$)", "g"), "~");
101
101
  }
102
102
 
103
103
  /** Redact secrets out of anything this machine produced. Pure. */
@@ -181,7 +181,10 @@ const NEVER_READ = Object.freeze([
181
181
  /(^|\/)id_(rsa|ed25519|ecdsa|dsa)(\.pub)?$/i,
182
182
  /(^|\/)local\.json$/i, // the Bridge Local loopback token
183
183
  /(^|\/)\.git\/config$/i, // can carry credentials in a remote URL
184
- /(^|\/)credentials?(\.[A-Za-z0-9]+)?$/i,
184
+ // `credentials.json` AND its dotfile spelling `.credentials.json` — the anchor
185
+ // used to accept only `/` before the word, so a dotfile in a granted project
186
+ // folder walked through the wall (ultrareview #123, bug_003).
187
+ /(^|[/.])credentials?(\.[A-Za-z0-9]+)?$/i,
185
188
  ]);
186
189
 
187
190
  /** Every spelling of a path this filesystem might consider the same file. */
@@ -691,6 +694,13 @@ const VERBS = {
691
694
  if (ext === ".json") {
692
695
  try { JSON.parse(content); } catch (e) { return { error: `That isn't valid JSON, so it wasn't written: ${e.message.slice(0, 120)}` }; }
693
696
  }
697
+ // The setup allowlist carries two TOML files (Codex). The "won't parse ⇒ not
698
+ // written" promise covered only JSON until ultrareview #123 (bug_008) — this is
699
+ // a structural check, not a full parser: unbalanced quotes/brackets, lines that
700
+ // are neither a table header nor `key = value`, unterminated multi-line strings.
701
+ if (ext === ".toml" && !tomlLooksValid(content)) {
702
+ return { error: "That doesn't look like valid TOML (unbalanced quotes/brackets or a malformed line), so it wasn't written." };
703
+ }
694
704
  if (content.includes("\0")) return { error: "Content contains a null byte." };
695
705
 
696
706
  let backup = null;
@@ -713,9 +723,15 @@ const VERBS = {
713
723
  if (!r.ok) return { error: r.error };
714
724
  const name = String(args.backup ?? "");
715
725
  if (!BACKUP_RE.test(name)) return { error: "Pass the backup filename this session created (…​.bak-chef-<timestamp>)." };
716
- const backupPath = path.join(path.dirname(r.path), name);
717
- if (path.basename(backupPath) !== name) return { error: "Invalid backup name." };
718
- if (!fs.existsSync(backupPath)) return { error: "That backup isn't there any more." };
726
+ // write_file placed the backup next to the RESOLVED file (r.real), so a
727
+ // symlinked setup file dotfile managers do this constantly — keeps its backup
728
+ // in the target directory. Look there first, then beside the symlink itself
729
+ // (ultrareview #123, bug_009: restore used to look only beside the symlink and
730
+ // report "isn't there any more" about a backup that existed).
731
+ const candidates = [path.join(path.dirname(r.real), name), path.join(path.dirname(r.path), name)];
732
+ if (candidates.some((c) => path.basename(c) !== name)) return { error: "Invalid backup name." };
733
+ const backupPath = candidates.find((c) => fs.existsSync(c));
734
+ if (!backupPath) return { error: "That backup isn't there any more." };
719
735
  try { fs.copyFileSync(backupPath, r.path); } catch (e) { return { error: `restore failed: ${e.code || e.message}` }; }
720
736
  return { path: args.path, restored_from: name };
721
737
  },
@@ -863,6 +879,46 @@ export function describeCall(call) {
863
879
  case "run": return `run ${a.template}`;
864
880
  case "doctor": return "run the setup doctor";
865
881
  case "env": return "look at what's installed";
882
+ // The three verbs a host must approve are the three that used to render as a
883
+ // bare verb name (ultrareview #123, bug_007). Say WHAT, not just which.
884
+ case "write_file": return `write ${a.path}${typeof a.content === "string" ? ` (${a.content.length} chars)` : ""}`;
885
+ case "restore_backup": return `restore ${a.path} from ${a.backup ?? "its backup"}`;
886
+ case "open_url": {
887
+ try { const u = new URL(String(a.url ?? "")); return `open ${u.host}${u.pathname === "/" ? "" : u.pathname} in your browser`; }
888
+ catch { return `open ${a.url ?? "a page"} in your browser`; }
889
+ }
866
890
  default: return String(call?.verb ?? "?");
867
891
  }
868
892
  }
893
+
894
+ /**
895
+ * Structural TOML sanity check for write_file. Deliberately NOT a parser: it
896
+ * rejects the ways a distracted agent breaks a config (unbalanced quotes or
897
+ * brackets, an unterminated multi-line string, a line that is neither a table
898
+ * header nor `key = value`) and accepts anything that has that shape.
899
+ */
900
+ export function tomlLooksValid(text) {
901
+ let multi = null; // the open multi-line string delimiter, if any
902
+ let depth = 0; // open [ / { across lines (multi-line arrays and inline tables)
903
+ for (const raw of String(text).split(/\r?\n/)) {
904
+ const line = raw.trim();
905
+ if (multi) { if (line.includes(multi)) multi = null; continue; }
906
+ if (!line || line.startsWith("#")) continue;
907
+ if (depth > 0) {
908
+ for (const ch of line) { if (ch === "[" || ch === "{") depth++; else if (ch === "]" || ch === "}") depth--; if (depth < 0) return false; }
909
+ continue;
910
+ }
911
+ if (/^\[\[?[^\]]+\]\]?\s*(#.*)?$/.test(line)) continue;
912
+ const m = /^[A-Za-z0-9_\-."']+\s*=\s*(.+)$/.exec(line);
913
+ if (!m) return false;
914
+ const v = m[1].trim();
915
+ if (v.startsWith('"""') || v.startsWith("'''")) {
916
+ const q = v.slice(0, 3);
917
+ if (!(v.length > 3 && v.endsWith(q))) multi = q;
918
+ continue;
919
+ }
920
+ if (((v.match(/(?<!\\)"/g) || []).length) % 2 !== 0) return false;
921
+ for (const ch of v) { if (ch === "[" || ch === "{") depth++; else if (ch === "]" || ch === "}") depth--; if (depth < 0) return false; }
922
+ }
923
+ return !multi && depth === 0;
924
+ }
package/local.mjs CHANGED
@@ -352,7 +352,10 @@ export function createLocalServer(deps) {
352
352
  // picks it up immediately (no restart, so a host can close the door NOW).
353
353
  if (route === "POST /hosting") {
354
354
  const body = (await readBody(req)) ?? {};
355
- const enabled = body.enabled !== false;
355
+ // A security toggle whose default is OFF must not fail open: an empty or
356
+ // malformed body used to mean "turn it on" (ultrareview #123, bug_011).
357
+ if (typeof body.enabled !== "boolean") return json(res, 400, { ok: false, error: "`enabled` must be true or false" });
358
+ const enabled = body.enabled;
356
359
  cfg.hosting = { ...(cfg.hosting ?? {}), enabled };
357
360
  try {
358
361
  saveConfigPatch((raw) => { raw.hosting = { ...(raw.hosting ?? {}), enabled }; });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cookbook-bridge",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Run your own Claude, Codex and Gemini subscriptions against your Cookbook workspaces. One approval connects every agent CLI on your machine, with a receipt for every run.",
5
5
  "type": "module",
6
6
  "bin": {