friend-assistant 0.1.15 → 0.1.16

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/README.md CHANGED
@@ -131,6 +131,7 @@ Connectors (Gmail, Chrome control, any MCP service) are managed inside the app,
131
131
  | Key | What it is |
132
132
  |---|---|
133
133
  | `provider` | Which model answers: `claude` (API key), `claude-code` (your subscription), `ollama` (this PC) or `openai` (any OpenAI-compatible server, such as OmniRoute). |
134
+ | `approvals` | How much Friend asks before acting: `manual` (asks every time, the default), `edits` (file work and the sandboxed code runner go ahead) or `auto` (commands and tools go ahead too). Risky commands (they need your PIN), the camera, adding connectors or plugins, and Friend's own data folder always ask, in every mode. The AI cannot change this. Easier: Esc → Settings → Approvals. |
134
135
  | `ownerName` | Your name, shown on the profile badge (a shield in the corner) while you are signed in; it shows whoever Friend just recognized when they speak. Empty shows "Owner". |
135
136
  | `apiKey`, `model` | The Anthropic API key and the Claude model used with it. |
136
137
  | `claudeCode.model`, `claudeCode.command`, `claudeCode.fallback` | The subscription route: model (`sonnet`, `haiku`, `opus`), the command that starts Claude Code, and `ollama` or `none` for what to use when Claude is unavailable. |
package/dist/BUILD.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "friend-assistant",
3
- "version": "0.1.15",
4
- "builtAt": "2026-10-10T11:42:54.414Z"
3
+ "version": "0.1.16",
4
+ "builtAt": "2026-10-10T11:58:33.881Z"
5
5
  }
package/dist/friend.mjs CHANGED
@@ -375,11 +375,21 @@ function matcherMatches(m, args) {
375
375
  // ../server/src/policy/engine.ts
376
376
  var ASK_EVERY_TIME = /* @__PURE__ */ new Set(["camera.snap"]);
377
377
  var MEMBER_TOOLS = /* @__PURE__ */ new Set(["weather.get", "stage.scene", "stage.show", "stage.update", "stage.hide", "stage.presence", "stage.mood"]);
378
+ var EDIT_TOOLS = /* @__PURE__ */ new Set(["fs.list", "fs.read", "fs.write", "code.run"]);
379
+ var isAdminTool = (tool) => tool.startsWith("connectors.") || tool.startsWith("plugins.");
378
380
  var PolicyEngine = class {
379
- rules;
380
- constructor(db, _config) {
381
+ constructor(db, config) {
382
+ this.config = config;
381
383
  this.rules = new RuleStore(db);
382
384
  }
385
+ config;
386
+ rules;
387
+ /** Whether the approval mode lets this tool go ahead without a card. Risky (T3) actions never get here. */
388
+ modeAllows(tool, protectedPath) {
389
+ if (protectedPath || isAdminTool(tool)) return false;
390
+ const mode = this.config.approvals;
391
+ return mode === "auto" || mode === "edits" && EDIT_TOOLS.has(tool);
392
+ }
383
393
  decide(input) {
384
394
  if (input.trust === "guest") return input.tool === "stage.presence" ? "allow" : "deny";
385
395
  if (input.trust === "member") return MEMBER_TOOLS.has(input.tool) ? "allow" : "deny";
@@ -391,7 +401,8 @@ var PolicyEngine = class {
391
401
  return "allow";
392
402
  case "T2":
393
403
  if (ASK_EVERY_TIME.has(input.tool)) return "ask";
394
- return this.rules.matches(input.tool, input.args) ? "allow" : "ask";
404
+ if (this.rules.matches(input.tool, input.args)) return "allow";
405
+ return this.modeAllows(input.tool, input.protectedPath) ? "allow" : "ask";
395
406
  case "T3":
396
407
  return "ask_with_pin";
397
408
  }
@@ -1904,7 +1915,8 @@ var Agent = class {
1904
1915
  if (call.name === "shell.run") tier = classifyShell(String(args.command), d.config.destructivePatterns);
1905
1916
  const p = pathArg(call.name, args);
1906
1917
  const insideRoots = p === void 0 ? true : lexicalInsideRoots(p, d.config.roots);
1907
- const decision = d.policy.decide({ tool: call.name, baseTier: tier, args, trust: ctx.trust, insideRoots });
1918
+ const protectedPath = p !== void 0 && lexicalInsideRoots(p, [d.config.dataDir]);
1919
+ const decision = d.policy.decide({ tool: call.name, baseTier: tier, args, trust: ctx.trust, insideRoots, protectedPath });
1908
1920
  let approvedOutside = false;
1909
1921
  const device = def.target === "device" ? d.devices.targetName() : null;
1910
1922
  if (decision === "deny") {
@@ -3680,6 +3692,7 @@ var defaults = (dataDir) => ({
3680
3692
  view: "companion",
3681
3693
  look: { ...DEFAULT_LOOK },
3682
3694
  ownerName: "",
3695
+ approvals: "manual",
3683
3696
  dataDir
3684
3697
  });
3685
3698
  function configPath(dataDir) {
@@ -3717,6 +3730,7 @@ function loadConfig(dataDir = defaultDataDir()) {
3717
3730
  view: stored.view === "minimal" || stored.view === "workspace" ? stored.view : "companion",
3718
3731
  look: sanitizeLook(stored.look),
3719
3732
  ownerName: typeof stored.ownerName === "string" ? stored.ownerName.replace(/[\u0000-\u001f\u007f]/g, " ").trim().slice(0, 40) : "",
3733
+ approvals: stored.approvals === "edits" || stored.approvals === "auto" ? stored.approvals : "manual",
3720
3734
  dataDir
3721
3735
  };
3722
3736
  }
@@ -5441,6 +5455,8 @@ var SettingsPatch = z11.object({
5441
5455
  view: ViewPreset,
5442
5456
  /** What the profile badge calls the owner. Empty clears it. */
5443
5457
  ownerName: z11.string().trim().max(40),
5458
+ /** How much Friend asks before acting. Loosening it is a change that needs the PIN. */
5459
+ approvals: z11.enum(["manual", "edits", "auto"]),
5444
5460
  look: z11.object({
5445
5461
  style: PresenceStyle,
5446
5462
  accent: z11.string().regex(/^#[0-9a-fA-F]{6}$/, "A colour like #3fd8ff"),
@@ -5490,6 +5506,7 @@ function settingsView(c) {
5490
5506
  apiKeySet: !!c.apiKey,
5491
5507
  view: c.view,
5492
5508
  ownerName: c.ownerName,
5509
+ approvals: c.approvals,
5493
5510
  look: { ...c.look },
5494
5511
  ollama: { url: c.ollama.url, model: c.ollama.model, vision: !!c.ollama.vision, contextTokens: c.ollama.contextTokens },
5495
5512
  openai: { url: c.openai.url, model: c.openai.model, vision: !!c.openai.vision, apiKeySet: !!c.openai.apiKey },
@@ -5513,6 +5530,7 @@ function refuseForAgent(raw) {
5513
5530
  const r = raw && typeof raw === "object" ? raw : {};
5514
5531
  const cc = r.claudeCode && typeof r.claudeCode === "object" ? r.claudeCode : {};
5515
5532
  const oa = r.openai && typeof r.openai === "object" ? r.openai : {};
5533
+ if ("approvals" in r) throw new SettingsError("How much Friend asks before acting can only be changed by the owner on the settings screen.");
5516
5534
  if ("apiKey" in r || "apiKey" in oa) throw new SettingsError("The API key can only be changed by the owner on the settings screen.");
5517
5535
  if ("command" in cc) throw new SettingsError("The Claude Code command can only be changed by the owner on the settings screen.");
5518
5536
  }
@@ -5565,6 +5583,7 @@ function applySettings(config, raw) {
5565
5583
  const restart = Object.keys(p.voice ?? {}).filter((k) => RESTART_VOICE.includes(k) && p.voice[k] !== config.voice[k]).map((k) => `voice.${k}`);
5566
5584
  config.provider = next.provider;
5567
5585
  if (p.view) config.view = p.view;
5586
+ if (p.approvals) config.approvals = p.approvals;
5568
5587
  if (p.ownerName !== void 0) config.ownerName = p.ownerName.replace(/[\u0000-\u001f\u007f]/g, " ").trim();
5569
5588
  if (p.look) config.look = sanitizeLook({ ...config.look, ...p.look });
5570
5589
  if (p.model) config.model = p.model;
@@ -7404,6 +7423,10 @@ var PARSERS = {
7404
7423
  if (name.length > 40) throw new Error("The name can be at most 40 characters");
7405
7424
  return name;
7406
7425
  },
7426
+ approvals: (v) => {
7427
+ if (v !== "manual" && v !== "edits" && v !== "auto") throw new Error("approvals must be manual, edits or auto");
7428
+ return v;
7429
+ },
7407
7430
  https: bool,
7408
7431
  port: (v) => {
7409
7432
  const n = Number(v);
@@ -7485,6 +7508,7 @@ var KEY_HELP = {
7485
7508
  port: "the web port (easier: friend lan port N)",
7486
7509
  https: "true serves a secure https:// address (Friend makes its own certificate), so a phone may use the microphone (easier: friend lan https on)",
7487
7510
  publicUrl: "the address people use when Friend is behind your own domain, or empty",
7511
+ approvals: "how much Friend asks before acting: manual (asks every time), edits (file work and sandboxed code go ahead) or auto (everything goes ahead except risky commands, the camera and changes to connectors/plugins). Easier: Esc \u2192 Settings \u2192 Approvals",
7488
7512
  ownerName: "your name, shown on the profile badge (the shield) in the corner of the screen; empty shows Owner",
7489
7513
  roots: "folders Friend may read and write freely, separated by commas",
7490
7514
  allowedOrigins: "extra web addresses allowed to connect, separated by commas",
@@ -9660,7 +9684,7 @@ async function daemonCommand(args, io2, deps = realDeps) {
9660
9684
  import crossSpawn5 from "cross-spawn";
9661
9685
  var PACKAGE = "friend-assistant";
9662
9686
  var real = {
9663
- current: () => true ? "0.1.15" : "development",
9687
+ current: () => true ? "0.1.16" : "development",
9664
9688
  latest: () => new Promise((resolve10) => {
9665
9689
  const child = crossSpawn5("npm", ["view", PACKAGE, "version", "--prefer-online"], { stdio: ["ignore", "pipe", "ignore"] });
9666
9690
  let out = "";
@@ -9948,7 +9972,7 @@ async function run(argv, io2) {
9948
9972
  case "version":
9949
9973
  case "--version":
9950
9974
  case "-v":
9951
- io2.out(true ? "0.1.15" : "development");
9975
+ io2.out(true ? "0.1.16" : "development");
9952
9976
  return 0;
9953
9977
  case "lan":
9954
9978
  return await lanCommand(rest, io2);