@venturewild/workspace 0.2.3 → 0.3.0

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.
@@ -0,0 +1,133 @@
1
+ // Support-channel consent (Phase 3, Pillar E) — the user's "VentureWild support
2
+ // may act on my machine" grant for the daemon-hosted support channel (Pillar A).
3
+ //
4
+ // SECURITY POSTURE: OFF by default + PER-INCIDENT + TIME-BOXED (Tuan's locked
5
+ // decisions, design doc Part 6). A grant is an explicit user gesture
6
+ // (`wild-workspace support allow`), carries a tier (1 = read-only diagnostics/
7
+ // logs; 2 = curated fixes) and an expiry, and auto-expires. Revoke is instant:
8
+ // delete the file (`wild-workspace support revoke`).
9
+ //
10
+ // CRITICAL — lives in the machine-global dir (`~/.wild-workspace`), NOT the
11
+ // per-workspace dataDir, because the **bmo-sync daemon** (a separate process) is
12
+ // the enforcement gate and reads this exact file by the same `~/.wild-workspace`
13
+ // convention. The relay never stores or checks consent — so a relay compromise
14
+ // can't forge it; the daemon's local read is the hard gate. (Contrast
15
+ // operator.mjs, which is server-read and keyed on dataDir.)
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import crypto from 'node:crypto';
20
+
21
+ export const SUPPORT_CONSENT_VERSION = 1;
22
+ // Tiers: 1 = read-only diagnostics/logs · 2 = curated fixes (restart/relink/
23
+ // reinstall) · 3 = agent-mediated operation (Phase 4 — support drives the agent
24
+ // on a task) · 4 = raw shell (the rare, loud escape hatch; Phase 4 PR 4.4).
25
+ // Tiers 3–4 are "operate" grants and the CLI requires explicit confirmation.
26
+ export const MAX_TIER = 4;
27
+ /** At/above this tier a grant lets support OPERATE the machine (agent or shell). */
28
+ export const OPERATE_TIER = 3;
29
+ /** Cap a single grant so a forgotten "allow" can't leave the door open forever. */
30
+ export const MAX_GRANT_MINUTES = 24 * 60;
31
+
32
+ export function consentFile(globalDir) {
33
+ return path.join(globalDir, 'support-consent.json');
34
+ }
35
+
36
+ /** The raw consent record, or null when absent/unparseable. No expiry check. */
37
+ export function loadConsent(globalDir) {
38
+ try {
39
+ const p = JSON.parse(fs.readFileSync(consentFile(globalDir), 'utf8'));
40
+ const tier = Number(p.tier);
41
+ const expiresAt = Number(p.expiresAt);
42
+ if (!Number.isFinite(tier) || !Number.isFinite(expiresAt)) return null;
43
+ return {
44
+ tier,
45
+ grantedAt: Number(p.grantedAt) || null,
46
+ expiresAt,
47
+ nonce: typeof p.nonce === 'string' ? p.nonce : null,
48
+ version: Number(p.version) || SUPPORT_CONSENT_VERSION,
49
+ };
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Effective consent status, with the expiry applied. `enabled` is true only when
57
+ * a grant exists AND hasn't expired. `now` is injectable for tests.
58
+ */
59
+ export function consentStatus(globalDir, { now = Date.now } = {}) {
60
+ const rec = loadConsent(globalDir);
61
+ const t = now();
62
+ if (!rec) return { enabled: false, tier: 0, expiresAt: null, remainingMs: 0, file: consentFile(globalDir) };
63
+ const remainingMs = Math.max(0, rec.expiresAt - t);
64
+ const enabled = remainingMs > 0;
65
+ return {
66
+ enabled,
67
+ tier: enabled ? rec.tier : 0,
68
+ grantedAt: rec.grantedAt,
69
+ expiresAt: rec.expiresAt,
70
+ remainingMs,
71
+ file: consentFile(globalDir),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Grant support consent at `tier` for `minutes`. Overwrites any prior grant (a
77
+ * fresh, explicit gesture). Returns the stored record, or null if it couldn't be
78
+ * persisted. `minutes` is clamped to (0, MAX_GRANT_MINUTES]; `tier` to [1, MAX_TIER].
79
+ */
80
+ export function grantConsent(globalDir, { tier = 1, minutes = 60, now = Date.now } = {}) {
81
+ const clampedTier = Math.min(MAX_TIER, Math.max(1, Math.floor(Number(tier) || 1)));
82
+ const clampedMinutes = Math.min(MAX_GRANT_MINUTES, Math.max(1, Math.floor(Number(minutes) || 1)));
83
+ const grantedAt = now();
84
+ const rec = {
85
+ tier: clampedTier,
86
+ grantedAt,
87
+ expiresAt: grantedAt + clampedMinutes * 60_000,
88
+ nonce: crypto.randomBytes(12).toString('base64url'),
89
+ version: SUPPORT_CONSENT_VERSION,
90
+ };
91
+ try {
92
+ fs.mkdirSync(globalDir, { recursive: true });
93
+ fs.writeFileSync(consentFile(globalDir), JSON.stringify(rec, null, 2), { mode: 0o600 });
94
+ } catch {
95
+ return null;
96
+ }
97
+ return rec;
98
+ }
99
+
100
+ /** Revoke instantly by deleting the file. Returns true if a grant was removed. */
101
+ export function revokeConsent(globalDir) {
102
+ try {
103
+ fs.rmSync(consentFile(globalDir));
104
+ return true;
105
+ } catch {
106
+ return false;
107
+ }
108
+ }
109
+
110
+ export function auditFile(globalDir) {
111
+ return path.join(globalDir, 'support-audit.jsonl');
112
+ }
113
+
114
+ /**
115
+ * The user-owned support audit: the most recent `limit` actions the daemon
116
+ * recorded (newest first). Written by the bmo-sync daemon (one JSON object per
117
+ * line) into the same machine-global dir; this is the read side the UI/CLI shows.
118
+ * Best-effort — a missing/corrupt file yields an empty list.
119
+ */
120
+ export function readAudit(globalDir, { limit = 50 } = {}) {
121
+ let raw;
122
+ try {
123
+ raw = fs.readFileSync(auditFile(globalDir), 'utf8');
124
+ } catch {
125
+ return [];
126
+ }
127
+ const lines = raw.split('\n').filter(Boolean);
128
+ const out = [];
129
+ for (const line of lines.slice(-limit)) {
130
+ try { out.push(JSON.parse(line)); } catch { /* skip a partial/corrupt line */ }
131
+ }
132
+ return out.reverse(); // newest first
133
+ }