friend-assistant 0.1.14 → 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
@@ -85,6 +85,8 @@ Every command starts with `friend`. If you run Friend from the source code, writ
85
85
 
86
86
  | Command | What it does | Use it when |
87
87
  |---|---|---|
88
+ | `friend password` | Sets a new sign-in password (asks twice, nothing is shown while you type). The old one is not needed, because you are at this PC, so it is also the way back in if you forgot it. Every browser and phone is signed out. | You forgot the password, or want a new one. From the screen: Esc → Settings → Security. |
89
+ | `friend pin` | Sets a new PIN, the number Friend asks for before risky actions. | You forgot the PIN, or want a new one. |
88
90
  | `friend daemon start` | Runs Friend in the background, with no terminal window needed. Prints the address, keeps a log in the data folder (`friend.log`). | You want Friend always available, even after you close the terminal. |
89
91
  | `friend daemon stop` | Stops the background Friend and the speech engine with it. | Shutting it down. |
90
92
  | `friend daemon restart` | Stops it and starts it again. | After changing a setting such as `friend lan on` or a different model. |
@@ -129,6 +131,7 @@ Connectors (Gmail, Chrome control, any MCP service) are managed inside the app,
129
131
  | Key | What it is |
130
132
  |---|---|
131
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. |
132
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". |
133
136
  | `apiKey`, `model` | The Anthropic API key and the Claude model used with it. |
134
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.14",
4
- "builtAt": "2026-10-10T06:29:10.737Z"
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") {
@@ -3031,7 +3043,7 @@ async function verifySecret(secret, stored) {
3031
3043
  const actual = await scryptAsync(secret, Buffer.from(saltHex, "hex"));
3032
3044
  return expected.length === actual.length && timingSafeEqual(expected, actual);
3033
3045
  }
3034
- var Auth = class {
3046
+ var Auth = class _Auth {
3035
3047
  constructor(db, audit, clock = Date.now) {
3036
3048
  this.db = db;
3037
3049
  this.audit = audit;
@@ -3116,6 +3128,48 @@ var Auth = class {
3116
3128
  else this.audit.log("owner", "pin.fail", { ip });
3117
3129
  return ok;
3118
3130
  }
3131
+ /** Checks a new password and/or PIN the same way setup does. Returns the problem, or null when both are fine. */
3132
+ static checkNewSecrets(input) {
3133
+ if (input.newPassword === void 0 && input.newPin === void 0) return "Give a new password or a new PIN.";
3134
+ if (input.newPin !== void 0 && !/^\d{6,}$/.test(input.newPin)) return "PIN must be at least 6 digits";
3135
+ if (input.newPassword !== void 0 && input.newPassword.length < 8) return "Password must be at least 8 characters";
3136
+ return null;
3137
+ }
3138
+ async store(input) {
3139
+ if (input.newPassword !== void 0) this.db.prepare("update owner set password_hash = ? where id = 1").run(await hashSecret(input.newPassword));
3140
+ if (input.newPin !== void 0) this.db.prepare("update owner set pin_hash = ? where id = 1").run(await hashSecret(input.newPin));
3141
+ }
3142
+ /**
3143
+ * Changes the password and/or PIN of a signed-in owner who proves it with the current password. Wrong guesses count toward
3144
+ * the same sign-in lock. A new password signs every other browser out (the one in `keepToken` stays signed in).
3145
+ */
3146
+ async changeSecrets(input, ip, keepToken) {
3147
+ const problem = _Auth.checkNewSecrets(input);
3148
+ if (problem) throw new Error(problem);
3149
+ const succeeded = this.begin("login", ip);
3150
+ const row = this.db.prepare("select password_hash h from owner where id = 1").get();
3151
+ const ok = row ? await verifySecret(input.current, row.h) : false;
3152
+ if (!ok) {
3153
+ this.audit.log("owner", "secrets.fail", { ip });
3154
+ throw new Error("Invalid credentials");
3155
+ }
3156
+ succeeded();
3157
+ await this.store(input);
3158
+ if (input.newPassword !== void 0) {
3159
+ if (keepToken) this.db.prepare("update sessions set revoked = 1 where token_hash != ?").run(sha256(keepToken));
3160
+ else this.db.prepare("update sessions set revoked = 1").run();
3161
+ }
3162
+ this.audit.log("owner", "secrets.changed", { password: input.newPassword !== void 0, pin: input.newPin !== void 0 });
3163
+ }
3164
+ /** For `friend password` / `friend pin` run on this PC: no current password needed (being on the PC is the proof). Signs everyone out. */
3165
+ async resetSecrets(input) {
3166
+ if (!this.hasOwner()) throw new Error("There is no owner yet. Run: friend setup");
3167
+ const problem = _Auth.checkNewSecrets(input);
3168
+ if (problem) throw new Error(problem);
3169
+ await this.store(input);
3170
+ if (input.newPassword !== void 0) this.db.prepare("update sessions set revoked = 1").run();
3171
+ this.audit.log("system", "secrets.reset", { password: input.newPassword !== void 0, pin: input.newPin !== void 0 });
3172
+ }
3119
3173
  createPairCode() {
3120
3174
  const code = randomBytes(5).toString("hex").toUpperCase();
3121
3175
  this.db.prepare("insert into pair_codes (code_hash, expires_at) values (?, ?)").run(sha256(code), this.clock() + PAIR_TTL_MS);
@@ -3638,6 +3692,7 @@ var defaults = (dataDir) => ({
3638
3692
  view: "companion",
3639
3693
  look: { ...DEFAULT_LOOK },
3640
3694
  ownerName: "",
3695
+ approvals: "manual",
3641
3696
  dataDir
3642
3697
  });
3643
3698
  function configPath(dataDir) {
@@ -3675,6 +3730,7 @@ function loadConfig(dataDir = defaultDataDir()) {
3675
3730
  view: stored.view === "minimal" || stored.view === "workspace" ? stored.view : "companion",
3676
3731
  look: sanitizeLook(stored.look),
3677
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",
3678
3734
  dataDir
3679
3735
  };
3680
3736
  }
@@ -4078,6 +4134,30 @@ function createGateway(deps) {
4078
4134
  reply.clearCookie(COOKIE_NAME, { path: "/" });
4079
4135
  return { ok: true };
4080
4136
  });
4137
+ app.post("/api/account/secret", async (req, reply) => {
4138
+ reply.header("cache-control", "no-store");
4139
+ const token = req.cookies[COOKIE_NAME];
4140
+ if (!token || !auth.resolveSession(token)) return reply.code(401).send({ error: "Please sign in again." });
4141
+ const body = z10.object({ current: z10.string().min(1).max(1024), newPassword: z10.string().max(1024).optional(), newPin: z10.string().max(128).optional() }).safeParse(req.body);
4142
+ if (!body.success) return reply.code(400).send({ error: "Invalid input" });
4143
+ const { current, newPassword, newPin } = body.data;
4144
+ try {
4145
+ await auth.changeSecrets({ current, newPassword, newPin }, req.ip, token);
4146
+ return { ok: true, signedOutOthers: newPassword !== void 0 };
4147
+ } catch (e) {
4148
+ if (e instanceof LockoutError) {
4149
+ const seconds = Math.max(1, Math.ceil(e.retryAfterMs / 1e3));
4150
+ return reply.code(429).header("retry-after", seconds).send({ error: `Too many wrong passwords. Try again in ${waitText(seconds)}.`, retryAfterSeconds: seconds });
4151
+ }
4152
+ if (e.message === "Invalid credentials") {
4153
+ const state = auth.loginState(req.ip);
4154
+ const seconds = Math.ceil(state.retryAfterMs / 1e3);
4155
+ if (seconds > 0) return reply.code(401).send({ error: `Wrong password. Locked for ${waitText(seconds)}.`, triesLeft: 0, retryAfterSeconds: seconds });
4156
+ return reply.code(401).send({ error: "The current password is wrong.", triesLeft: state.triesLeft });
4157
+ }
4158
+ return reply.code(400).send({ error: e.message });
4159
+ }
4160
+ });
4081
4161
  const pairLimiter = new RateLimiter(Date.now);
4082
4162
  app.post("/api/pair", async (req, reply) => {
4083
4163
  const body = z10.object({ code: z10.string().min(1).max(64), name: z10.string().min(1).max(64) }).safeParse(req.body);
@@ -5375,6 +5455,8 @@ var SettingsPatch = z11.object({
5375
5455
  view: ViewPreset,
5376
5456
  /** What the profile badge calls the owner. Empty clears it. */
5377
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"]),
5378
5460
  look: z11.object({
5379
5461
  style: PresenceStyle,
5380
5462
  accent: z11.string().regex(/^#[0-9a-fA-F]{6}$/, "A colour like #3fd8ff"),
@@ -5424,6 +5506,7 @@ function settingsView(c) {
5424
5506
  apiKeySet: !!c.apiKey,
5425
5507
  view: c.view,
5426
5508
  ownerName: c.ownerName,
5509
+ approvals: c.approvals,
5427
5510
  look: { ...c.look },
5428
5511
  ollama: { url: c.ollama.url, model: c.ollama.model, vision: !!c.ollama.vision, contextTokens: c.ollama.contextTokens },
5429
5512
  openai: { url: c.openai.url, model: c.openai.model, vision: !!c.openai.vision, apiKeySet: !!c.openai.apiKey },
@@ -5447,6 +5530,7 @@ function refuseForAgent(raw) {
5447
5530
  const r = raw && typeof raw === "object" ? raw : {};
5448
5531
  const cc = r.claudeCode && typeof r.claudeCode === "object" ? r.claudeCode : {};
5449
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.");
5450
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.");
5451
5535
  if ("command" in cc) throw new SettingsError("The Claude Code command can only be changed by the owner on the settings screen.");
5452
5536
  }
@@ -5499,6 +5583,7 @@ function applySettings(config, raw) {
5499
5583
  const restart = Object.keys(p.voice ?? {}).filter((k) => RESTART_VOICE.includes(k) && p.voice[k] !== config.voice[k]).map((k) => `voice.${k}`);
5500
5584
  config.provider = next.provider;
5501
5585
  if (p.view) config.view = p.view;
5586
+ if (p.approvals) config.approvals = p.approvals;
5502
5587
  if (p.ownerName !== void 0) config.ownerName = p.ownerName.replace(/[\u0000-\u001f\u007f]/g, " ").trim();
5503
5588
  if (p.look) config.look = sanitizeLook({ ...config.look, ...p.look });
5504
5589
  if (p.model) config.model = p.model;
@@ -7338,6 +7423,10 @@ var PARSERS = {
7338
7423
  if (name.length > 40) throw new Error("The name can be at most 40 characters");
7339
7424
  return name;
7340
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
+ },
7341
7430
  https: bool,
7342
7431
  port: (v) => {
7343
7432
  const n = Number(v);
@@ -7419,6 +7508,7 @@ var KEY_HELP = {
7419
7508
  port: "the web port (easier: friend lan port N)",
7420
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)",
7421
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",
7422
7512
  ownerName: "your name, shown on the profile badge (the shield) in the corner of the screen; empty shows Owner",
7423
7513
  roots: "folders Friend may read and write freely, separated by commas",
7424
7514
  allowedOrigins: "extra web addresses allowed to connect, separated by commas",
@@ -9594,7 +9684,7 @@ async function daemonCommand(args, io2, deps = realDeps) {
9594
9684
  import crossSpawn5 from "cross-spawn";
9595
9685
  var PACKAGE = "friend-assistant";
9596
9686
  var real = {
9597
- current: () => true ? "0.1.14" : "development",
9687
+ current: () => true ? "0.1.16" : "development",
9598
9688
  latest: () => new Promise((resolve10) => {
9599
9689
  const child = crossSpawn5("npm", ["view", PACKAGE, "version", "--prefer-online"], { stdio: ["ignore", "pipe", "ignore"] });
9600
9690
  let out = "";
@@ -9772,6 +9862,35 @@ async function setupCommand3(io2) {
9772
9862
  }
9773
9863
  }
9774
9864
 
9865
+ // ../agent-cli/src/commands/secret.ts
9866
+ async function secretCommand(kind, io2) {
9867
+ const db = openDb(io2.dataDir);
9868
+ try {
9869
+ const auth = new Auth(db, new Audit(db));
9870
+ if (!auth.hasOwner()) {
9871
+ io2.err("There is no owner yet. Run: friend setup");
9872
+ return 1;
9873
+ }
9874
+ const label2 = kind === "password" ? "password" : "PIN";
9875
+ const first2 = await io2.ask(`New ${label2} (${kind === "password" ? "at least 8 characters" : "at least 6 digits"}): `, { hidden: true });
9876
+ const problem = Auth.checkNewSecrets(kind === "password" ? { newPassword: first2 } : { newPin: first2 });
9877
+ if (problem) {
9878
+ io2.err(problem);
9879
+ return 1;
9880
+ }
9881
+ const again = await io2.ask(`Type the new ${label2} again: `, { hidden: true });
9882
+ if (again !== first2) {
9883
+ io2.err(`The two ${label2}s are not the same. Nothing was changed.`);
9884
+ return 1;
9885
+ }
9886
+ await auth.resetSecrets(kind === "password" ? { newPassword: first2 } : { newPin: first2 });
9887
+ io2.out(kind === "password" ? "Password changed. Every browser and phone has been signed out; sign in again with the new password." : "PIN changed.");
9888
+ return 0;
9889
+ } finally {
9890
+ db.close();
9891
+ }
9892
+ }
9893
+
9775
9894
  // ../agent-cli/src/commands/status.ts
9776
9895
  async function statusCommand3(io2) {
9777
9896
  const cfg = loadConfig(io2.dataDir);
@@ -9796,6 +9915,8 @@ var USAGE4 = `Usage: friend <command>
9796
9915
  onboard guided setup, step by step: checks this PC, installs what is missing, asks what you want
9797
9916
  doctor check what this PC has and what is missing (changes nothing)
9798
9917
  setup create the owner account (password, PIN, assistant name)
9918
+ password change the sign-in password (signs every browser out)
9919
+ pin change the PIN for risky actions
9799
9920
  start run the gateway and a local device node
9800
9921
  daemon start|stop|restart|status run Friend in the background (no terminal needed), stop it, or see if it is running
9801
9922
  upgrade [--force] stop the background Friend, install the newest version, start it again
@@ -9821,6 +9942,10 @@ async function run(argv, io2) {
9821
9942
  switch (cmd) {
9822
9943
  case "setup":
9823
9944
  return await setupCommand3(io2);
9945
+ case "password":
9946
+ return await secretCommand("password", io2);
9947
+ case "pin":
9948
+ return await secretCommand("pin", io2);
9824
9949
  case "start":
9825
9950
  return await startCommand(io2);
9826
9951
  case "daemon":
@@ -9847,7 +9972,7 @@ async function run(argv, io2) {
9847
9972
  case "version":
9848
9973
  case "--version":
9849
9974
  case "-v":
9850
- io2.out(true ? "0.1.14" : "development");
9975
+ io2.out(true ? "0.1.16" : "development");
9851
9976
  return 0;
9852
9977
  case "lan":
9853
9978
  return await lanCommand(rest, io2);