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 +1 -0
- package/dist/BUILD.json +2 -2
- package/dist/friend.mjs +30 -6
- package/dist/web/assets/{index-1xKxmOaM.js → index-BmlBBnge.js} +22 -22
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
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
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
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
9975
|
+
io2.out(true ? "0.1.16" : "development");
|
|
9952
9976
|
return 0;
|
|
9953
9977
|
case "lan":
|
|
9954
9978
|
return await lanCommand(rest, io2);
|