privateer-agent 0.12.36 β†’ 0.12.37

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.
@@ -9,7 +9,7 @@
9
9
  import { verifyModelPosture, TIERS, type PrivacyTier } from "pi-privacy";
10
10
  import { accountPosture } from "../src/providers/account.ts";
11
11
  import { type Palette, paletteFor } from "../src/ui/palette.ts";
12
- import { privacyDisabled } from "../src/config/privacyDisabled.ts";
12
+ import { onPrivacyDisabledChange, privacyDisabled } from "../src/config/privacyDisabled.ts";
13
13
 
14
14
  const DOT: Record<string, string> = { green: "🟒", yellow: "🟑", red: "πŸ”΄", neutral: "βšͺ" };
15
15
 
@@ -79,6 +79,21 @@ export default function privateerPosture(pi: any): void {
79
79
  }
80
80
  };
81
81
 
82
- pi.on("model_select", (event: any, ctx: any) => update(event?.model?.provider, event?.model?.id, ctx));
83
- pi.on("session_start", (_event: any, ctx: any) => update(ctx?.model?.provider, ctx?.model?.id, ctx));
82
+ // Keep the last ctx a real event handed us: the badge has to be repainted when the
83
+ // privacy flag moves, and the flag can now move with no event behind it at all β€”
84
+ // the app's shield toggle writes it over the relay (RemoteBridge's onPrivacy), so
85
+ // `/privacy` is no longer the only driver and the CLI's own badge would otherwise
86
+ // sit on "⛉ Trusted Execution" over a session whose filter a phone just took down.
87
+ let lastCtx: any = null;
88
+ pi.on("model_select", (event: any, ctx: any) => {
89
+ lastCtx = ctx;
90
+ return update(event?.model?.provider, event?.model?.id, ctx);
91
+ });
92
+ pi.on("session_start", (_event: any, ctx: any) => {
93
+ lastCtx = ctx;
94
+ return update(ctx?.model?.provider, ctx?.model?.id, ctx);
95
+ });
96
+ onPrivacyDisabledChange(() => {
97
+ void update(lastCtx?.model?.provider, lastCtx?.model?.id, lastCtx);
98
+ });
84
99
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.36",
3
+ "version": "0.12.37",
4
4
  "description": "Privacy-first terminal coding agent β€” bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,11 +24,27 @@ export function privacyDisabled(): boolean {
24
24
  return process.env[ENV] === "1" || process.env[ALT_ENV] === "1";
25
25
  }
26
26
 
27
+ // WHO ELSE NEEDS TO KNOW. The env var is the store, but it is not observable β€” and the
28
+ // switch now has two drivers on the same session: `/privacy off` typed into a composer,
29
+ // and the app's own shield toggle (RemoteBridge's onPrivacy). Whichever one moves it, the
30
+ // app's switch has to follow, or the desktop shows a shield over a session whose filter
31
+ // its own composer just turned off. Listeners fire on a real CHANGE only, so a re-assert
32
+ // is not an event; failures are swallowed, because a subscriber that throws must not
33
+ // leave the flag half-applied.
34
+ const listeners = new Set<(disabled: boolean) => void>();
35
+
36
+ /** Watch the disabled state. Returns an unsubscribe. Fires only when the value changes. */
37
+ export function onPrivacyDisabledChange(fn: (disabled: boolean) => void): () => void {
38
+ listeners.add(fn);
39
+ return () => listeners.delete(fn);
40
+ }
41
+
27
42
  /**
28
43
  * Set the disabled state. Modifies process.env so every module copy and child process agrees.
29
44
  * Returns the new state.
30
45
  */
31
46
  export function setPrivacyDisabled(disabled: boolean): boolean {
47
+ const before = privacyDisabled();
32
48
  if (disabled) {
33
49
  process.env[ENV] = "1";
34
50
  process.env[ALT_ENV] = "1";
@@ -36,6 +52,11 @@ export function setPrivacyDisabled(disabled: boolean): boolean {
36
52
  delete process.env[ENV];
37
53
  delete process.env[ALT_ENV];
38
54
  }
55
+ if (before !== disabled) {
56
+ for (const fn of listeners) {
57
+ try { fn(disabled); } catch { /* a watcher must not break the toggle */ }
58
+ }
59
+ }
39
60
  return disabled;
40
61
  }
41
62
 
@@ -126,6 +126,13 @@ export interface RelayCallbacks {
126
126
  // bypass mode instead of relaying every action, so the agent runs to completion.
127
127
  // Dangerous/destructive actions still relay β€” they sit above bypass locally too.
128
128
  onNoQuarter?: (on: boolean) => void;
129
+ // The app toggled the privacy filter. `off` true = pi-privacy is disabled for this
130
+ // session, exactly as `/privacy off` does it: no outbound PII scan or prompt, no
131
+ // tool-result credential redaction, no exfiltration or downgrade guard. The app is
132
+ // the only surface that can ask for this without a keyboard on this machine, so the
133
+ // frame carries the SAME state the command writes (process.env, shared with
134
+ // subagent children) rather than a second flag beside it.
135
+ onPrivacy?: (off: boolean) => void;
129
136
  // A controller attached β€” push a transcript snapshot so it can catch up.
130
137
  onControllerAttached: () => void;
131
138
  // The last controller went away (app closed / socket reaped). The terminal keeps
@@ -696,6 +703,7 @@ export class RelayClient {
696
703
  seq?: number;
697
704
  data?: string;
698
705
  on?: boolean;
706
+ off?: boolean;
699
707
  value?: string;
700
708
  source?: string;
701
709
  description?: string;
@@ -763,6 +771,9 @@ export class RelayClient {
763
771
  case "no_quarter":
764
772
  this.cb.onNoQuarter?.(frame.on === true);
765
773
  break;
774
+ case "privacy":
775
+ this.cb.onPrivacy?.(frame.off === true);
776
+ break;
766
777
  case "controller_attached":
767
778
  this.cb.onControllerAttached();
768
779
  break;
@@ -1158,6 +1169,15 @@ export class RelayClient {
1158
1169
  this.rawSend({ type: "no_quarter", on });
1159
1170
  }
1160
1171
 
1172
+ // Echo the privacy-filter state up to the controller, same contract as
1173
+ // sendNoQuarter: an ack of the app's toggle, and a resync on controller attach.
1174
+ // Only ever sent while the filter is OFF β€” the app defaults its switch to "on"
1175
+ // (the safe reading), so a terminal too old to know this frame, or one running
1176
+ // with the filter intact, correctly shows protected.
1177
+ sendPrivacy(off: boolean): void {
1178
+ this.rawSend({ type: "privacy", off });
1179
+ }
1180
+
1161
1181
  // Push this terminal's live context (selected model, agent version) to a
1162
1182
  // controller so the app's session banner reflects reality instead of a stub.
1163
1183
  // Sent on controller attach β€” like the snapshot/no_quarter resync. NON-PII ONLY
@@ -12,6 +12,9 @@
12
12
 
13
13
  import { randomUUID } from "node:crypto";
14
14
  import { noQuarterActive, setNoQuarter } from "../permissions/noQuarter.ts";
15
+ // Pure env-var accessors (see config/privacyDisabled.ts) β€” no Pi, no node builtins,
16
+ // so this stays safe to import from the bridge, which boot-ordered code reaches.
17
+ import { onPrivacyDisabledChange, privacyDisabled, setPrivacyDisabled } from "../config/privacyDisabled.ts";
15
18
  import type { EngineEvent } from "../engine/events.ts";
16
19
  import type { PermissionRequest } from "../permissions/gate.ts";
17
20
  import type { AskOutcome } from "../permissions/modeGate.ts";
@@ -56,6 +59,10 @@ export interface RelayLike {
56
59
  // turns into a duplicate of something the app already displayed.
57
60
  hasController?(): boolean;
58
61
  sendNoQuarter(on: boolean): void;
62
+ // Echo the privacy-filter state (off = pi-privacy disabled). Optional so a
63
+ // transport built against an older shape still satisfies the interface; the
64
+ // bridge's callers all treat a missing echo as "filter on", which is the safe read.
65
+ sendPrivacy?(off: boolean): void;
59
66
  sendFile(file: { name: string; mediaType: string; base64: string; size: number }): Promise<{ ok: boolean; reason?: string }>;
60
67
  sendNotice(text: string): void;
61
68
  sendCommands(commands: { name: string; description?: string }[]): void;
@@ -190,7 +197,25 @@ export class RemoteBridge {
190
197
  private turnPrompt = "";
191
198
  private turnText = "";
192
199
 
193
- constructor(private readonly cfg: RemoteBridgeConfig) {}
200
+ // Unsubscribes the privacy watcher below. Held so a bridge that outlives its window
201
+ // (the desktop builds one per window, in one process) stops writing to a transport
202
+ // whose renderer is gone.
203
+ private readonly unwatchPrivacy: () => void;
204
+
205
+ constructor(private readonly cfg: RemoteBridgeConfig) {
206
+ // The privacy switch has a second driver: `/privacy off` typed into the composer
207
+ // (or into the CLI beside a driving app). Watch the flag itself rather than only
208
+ // the frame, so the app's shield follows the terminal however it was moved β€” and
209
+ // on the desktop, where the flag is process-wide, so does every OTHER window's,
210
+ // which is the truth: they share the env var this writes.
211
+ this.unwatchPrivacy = onPrivacyDisabledChange((off) => this.relay?.sendPrivacy?.(off));
212
+ }
213
+
214
+ /** Release what outlives a socket. Safe to call twice. */
215
+ dispose(): void {
216
+ this.unwatchPrivacy();
217
+ this.relay = undefined;
218
+ }
194
219
 
195
220
  // Wire the outbound relay once it's constructed (RelayClient needs `callbacks`
196
221
  // at construction, so the relay is attached right after).
@@ -286,10 +311,29 @@ export class RemoteBridge {
286
311
  setNoQuarter(on);
287
312
  this.relay?.sendNoQuarter(on); // echo the ack back so the app's toggle syncs
288
313
  },
314
+ // The app's privacy switch. It writes the SAME state `/privacy off` writes β€” the
315
+ // env var every module copy and subagent child reads β€” so a session toggled from
316
+ // the app and one toggled from the keyboard are the same session, and the CLI's
317
+ // own `/privacy status` tells the truth about what the app did. Echoed back so
318
+ // the app's switch reflects what this terminal is actually doing rather than what
319
+ // it was asked to do.
320
+ onPrivacy: (off) => {
321
+ const changed = privacyDisabled() !== off;
322
+ setPrivacyDisabled(off);
323
+ // A real change is echoed by the watcher in the constructor (which also covers
324
+ // `/privacy` typed at a composer). Ack a no-op ask here so an app whose switch
325
+ // was already out of step still gets an answer rather than silence.
326
+ if (!changed) this.relay?.sendPrivacy?.(off);
327
+ },
289
328
  onControllerAttached: () => {
290
329
  if (this.noQuarter || noQuarterActive()) {
291
330
  this.relay?.sendNoQuarter(true);
292
331
  }
332
+ // Resync the privacy switch the same way, and for a sharper reason: the filter
333
+ // may have been turned off from the keyboard, or by a launch flag, or by an
334
+ // earlier session of this app β€” an attaching controller must not paint a shield
335
+ // over a terminal that has none. Sent only when OFF, so silence means protected.
336
+ if (privacyDisabled()) this.relay?.sendPrivacy?.(true);
293
337
  this.cfg.onControllerAttached?.();
294
338
  },
295
339
  // The app left while we're still running. Same posture as a dropped socket: stop
@@ -319,6 +363,9 @@ export class RemoteBridge {
319
363
  getRemote = (): boolean => this.remote;
320
364
  getNoQuarter = (): boolean => this.noQuarter || noQuarterActive();
321
365
 
366
+ /** True while the privacy filter is disabled for this session (`/privacy off`). */
367
+ getPrivacyDisabled = (): boolean => privacyDisabled();
368
+
322
369
  // The gate's remote approver: relay the request to the app and await its
323
370
  // allow/deny. Fail closed if no controller, on abort, or on disconnect. (The gate
324
371
  // also wraps this in its own timeout, so a silent app can't wedge the turn.)