secure-agents 0.0.0-stage → 0.7.2
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/AGENTS.md +63 -0
- package/README.md +206 -2
- package/dist/agents.js +31 -0
- package/dist/brain.js +70 -0
- package/dist/cli.js +310 -0
- package/dist/config.js +62 -0
- package/dist/control.js +251 -0
- package/dist/guard.js +98 -0
- package/dist/identity.js +38 -0
- package/dist/invite.js +52 -0
- package/dist/link.js +964 -0
- package/dist/mcp.js +190 -0
- package/dist/net.js +103 -0
- package/dist/notify.js +61 -0
- package/dist/passkey.js +73 -0
- package/dist/prompt.js +46 -0
- package/dist/protocol.js +47 -0
- package/dist/relay.js +222 -0
- package/dist/scan.js +41 -0
- package/dist/setup.js +35 -0
- package/dist/store.js +125 -0
- package/dist/ui.js +366 -0
- package/dist/update.js +68 -0
- package/dist/vault.js +87 -0
- package/dist/waker.js +281 -0
- package/package.json +40 -4
package/dist/ui.js
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// The approval page, served only on 127.0.0.1; everything on it needs the passphrase sign-in, and every change needs a passkey touch.
|
|
2
|
+
export function approvalPage() {
|
|
3
|
+
return `<!doctype html>
|
|
4
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
5
|
+
<title>Secure</title>
|
|
6
|
+
<style>
|
|
7
|
+
:root{--bg:#fafafa;--surface:#fff;--sunk:#f4f4f5;--ink:#0a0a0a;--ink2:#3f3f46;--muted:#71717a;--line:#e4e4e7;--line2:#d4d4d8;--inv:#0a0a0a;--inv-ink:#fafafa;--ok:#16a34a;--bad:#dc2626;--bad-soft:#fef2f2;--warn:#b45309;--warn-soft:#fffbeb;--ring:rgba(10,10,10,.08);--shadow:0 1px 2px rgba(0,0,0,.04),0 8px 24px -12px rgba(0,0,0,.08)}
|
|
8
|
+
@media (prefers-color-scheme:dark){:root{--bg:#09090b;--surface:#0f0f11;--sunk:#18181b;--ink:#fafafa;--ink2:#d4d4d8;--muted:#a1a1aa;--line:#27272a;--line2:#3f3f46;--inv:#fafafa;--inv-ink:#09090b;--ok:#4ade80;--bad:#f87171;--bad-soft:#1f1315;--warn:#fbbf24;--warn-soft:#1c1708;--ring:rgba(255,255,255,.08);--shadow:none;color-scheme:dark}}
|
|
9
|
+
*{box-sizing:border-box} [hidden]{display:none!important}
|
|
10
|
+
body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.55 ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;-webkit-font-smoothing:antialiased}
|
|
11
|
+
body::before{content:"";position:fixed;inset:0;pointer-events:none;background-image:linear-gradient(var(--line) 1px,transparent 1px),linear-gradient(90deg,var(--line) 1px,transparent 1px);background-size:48px 48px;opacity:.35;-webkit-mask-image:radial-gradient(ellipse 80% 50% at 50% 0%,#000 40%,transparent 100%);mask-image:radial-gradient(ellipse 80% 50% at 50% 0%,#000 40%,transparent 100%)}
|
|
12
|
+
main{position:relative;max-width:880px;margin:0 auto;padding:0 20px 80px}
|
|
13
|
+
h1{font-size:15px;font-weight:600;margin:0;letter-spacing:-.02em} h2{font-size:20px;font-weight:600;letter-spacing:-.03em;margin:0} h3{font-size:14px;font-weight:600;margin:0;letter-spacing:-.01em}
|
|
14
|
+
p{margin:0} a{color:var(--ink);text-underline-offset:3px} .muted{color:var(--muted)} .small{font-size:12.5px}
|
|
15
|
+
code,.mono{font:12.5px ui-monospace,"SF Mono",Menlo,Consolas,monospace} code{background:var(--sunk);border:1px solid var(--line);padding:1px 6px;border-radius:6px}
|
|
16
|
+
.top{position:sticky;top:0;z-index:5;margin:0 -20px;padding:0 20px;background:color-mix(in srgb,var(--bg) 80%,transparent);backdrop-filter:saturate(1.4) blur(12px);-webkit-backdrop-filter:saturate(1.4) blur(12px)}
|
|
17
|
+
.bar{display:flex;align-items:center;gap:12px;height:60px}
|
|
18
|
+
.beam{position:relative;height:1px;background:var(--line);overflow:hidden}
|
|
19
|
+
.beam::after{content:"";position:absolute;top:0;left:-30%;width:30%;height:1px;background:linear-gradient(90deg,transparent,var(--ink),transparent);animation:beam 4.5s ease-in-out infinite}
|
|
20
|
+
@keyframes beam{0%{left:-30%}60%,100%{left:100%}}
|
|
21
|
+
@media (prefers-reduced-motion:reduce){.beam::after{animation:none;display:none}}
|
|
22
|
+
.mark{width:28px;height:28px;border-radius:8px;background:var(--inv);color:var(--inv-ink);display:grid;place-items:center;flex:none}
|
|
23
|
+
.pill{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--ink2);border:1px solid var(--line);border-radius:999px;padding:3px 10px;background:var(--surface);white-space:nowrap}
|
|
24
|
+
.dot{width:7px;height:7px;border-radius:50%;background:var(--muted);flex:none} .dot.on{background:var(--ok);box-shadow:0 0 0 3px color-mix(in srgb,var(--ok) 20%,transparent)}
|
|
25
|
+
.grow{flex:1}
|
|
26
|
+
nav.tabs{display:flex;gap:4px;padding:10px 0 0;overflow-x:auto;scrollbar-width:none}
|
|
27
|
+
nav.tabs button{border:0;background:none;border-radius:8px 8px 0 0;padding:8px 12px 10px;color:var(--muted);font-weight:500;position:relative;white-space:nowrap}
|
|
28
|
+
nav.tabs button[aria-selected=true]{color:var(--ink)} nav.tabs button[aria-selected=true]::after{content:"";position:absolute;left:10px;right:10px;bottom:-1px;height:2px;border-radius:2px;background:var(--ink)}
|
|
29
|
+
nav.tabs button:hover{color:var(--ink)}
|
|
30
|
+
.count{display:inline-grid;place-items:center;min-width:18px;height:18px;padding:0 5px;margin-left:6px;border-radius:999px;background:var(--inv);color:var(--inv-ink);font-size:11px;font-weight:600} .count:empty{display:none}
|
|
31
|
+
.page{padding-top:28px;display:grid;gap:16px}
|
|
32
|
+
.head{display:grid;gap:4px;margin-bottom:4px}
|
|
33
|
+
.card{background:var(--surface);border:1px solid var(--line);border-radius:14px;padding:18px;display:grid;gap:14px;box-shadow:var(--shadow);transition:border-color .2s}
|
|
34
|
+
.card:hover{border-color:var(--line2)}
|
|
35
|
+
.card.alert{border-color:color-mix(in srgb,var(--bad) 45%,var(--line))} .card.alert .title{color:var(--bad)}
|
|
36
|
+
.card.hold{border-color:color-mix(in srgb,var(--warn) 45%,var(--line))}
|
|
37
|
+
.title{font-weight:600}
|
|
38
|
+
.warn{color:var(--bad);font-size:13.5px;display:flex;gap:8px} .warn::before{content:"!";display:inline-grid;place-items:center;flex:none;width:18px;height:18px;border-radius:50%;background:var(--bad);color:#fff;font-size:11px;font-weight:700;margin-top:1px}
|
|
39
|
+
.info{color:var(--warn);background:var(--warn-soft);border-radius:8px;padding:8px 12px;font-size:13px}
|
|
40
|
+
pre{white-space:pre-wrap;word-break:break-word;background:var(--sunk);border:1px solid var(--line);padding:12px 14px;border-radius:10px;margin:0;max-height:300px;overflow:auto;font:12.5px/1.6 ui-monospace,"SF Mono",Menlo,monospace}
|
|
41
|
+
.row{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
|
42
|
+
button{font:inherit;font-size:13.5px;font-weight:500;border-radius:9px;padding:7px 14px;border:1px solid var(--line);background:var(--surface);color:var(--ink);cursor:pointer;transition:background .15s,border-color .15s,transform .05s}
|
|
43
|
+
button:hover{border-color:var(--line2);background:var(--sunk)} button:active{transform:translateY(1px)} button:disabled{opacity:.4;cursor:not-allowed}
|
|
44
|
+
button.primary{background:var(--inv);border-color:var(--inv);color:var(--inv-ink)} button.primary:hover{opacity:.88;background:var(--inv)}
|
|
45
|
+
button.danger{color:var(--bad)} button.ghost{border-color:transparent;background:none;color:var(--muted)} button.ghost:hover{color:var(--ink);background:var(--sunk)}
|
|
46
|
+
button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid var(--ink);outline-offset:2px}
|
|
47
|
+
.field,select{font:inherit;font-size:13.5px;padding:7px 10px;border-radius:9px;border:1px solid var(--line);background:var(--surface);color:var(--ink)} .field{flex:1;min-width:200px}
|
|
48
|
+
.seg{display:inline-flex;padding:3px;border-radius:10px;background:var(--sunk);border:1px solid var(--line);gap:2px}
|
|
49
|
+
.seg button{border:0;background:none;padding:5px 11px;border-radius:7px;color:var(--muted);font-size:12.5px}
|
|
50
|
+
.seg button[aria-pressed=true]{background:var(--surface);color:var(--ink);box-shadow:0 1px 2px rgba(0,0,0,.08),0 0 0 1px var(--line)}
|
|
51
|
+
.rules{display:grid;gap:0}
|
|
52
|
+
.rule{display:flex;align-items:center;gap:16px;padding:11px 0;border-top:1px solid var(--line)} .rule:first-child{border-top:0}
|
|
53
|
+
.rule .what{flex:1;min-width:0} .rule .what .small{display:block}
|
|
54
|
+
@media (max-width:620px){.rule{flex-direction:column;align-items:flex-start;gap:8px}}
|
|
55
|
+
.sub{font-size:11.5px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}
|
|
56
|
+
.folders{display:grid;gap:6px} .page>*,.card>*,.folders>*{min-width:0}
|
|
57
|
+
.empty{color:var(--muted);font-size:13.5px;padding:28px;text-align:center;border:1px dashed var(--line2);border-radius:14px;background:var(--surface)}
|
|
58
|
+
.hist{display:grid}
|
|
59
|
+
.hrow{display:grid;grid-template-columns:72px 18px 1fr auto;gap:10px;align-items:center;padding:10px 4px;border-top:1px solid var(--line);cursor:pointer;border-radius:8px}
|
|
60
|
+
.hrow:first-child{border-top:0} .hrow:hover{background:var(--sunk)}
|
|
61
|
+
.hrow .t{color:var(--muted);font-size:12.5px;font-variant-numeric:tabular-nums} .hrow .arrow{color:var(--muted)} .hrow .sum{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
62
|
+
.status{font-size:11.5px;border:1px solid var(--line);border-radius:999px;padding:2px 8px;color:var(--ink2);white-space:nowrap} .status.denied,.status.failed{color:var(--bad);border-color:color-mix(in srgb,var(--bad) 40%,var(--line))} .status.awaiting_human{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 40%,var(--line))}
|
|
63
|
+
.detail{padding:4px 4px 14px 100px;display:grid;gap:10px} @media (max-width:620px){.detail{padding-left:4px}.hrow{grid-template-columns:60px 14px 1fr}.hrow .status{display:none}}
|
|
64
|
+
.gate{min-height:100vh;display:grid;place-items:center;position:relative}
|
|
65
|
+
.gate .card{max-width:400px;width:100%;padding:32px;gap:16px}
|
|
66
|
+
.gate .mark{width:40px;height:40px;border-radius:11px}
|
|
67
|
+
.gate input[type=password]{width:100%;font:inherit;padding:10px 12px;border-radius:10px;border:1px solid var(--line);background:var(--sunk);color:var(--ink)}
|
|
68
|
+
.gate button.primary{width:100%;padding:10px}
|
|
69
|
+
.toasts{position:fixed;right:20px;bottom:20px;z-index:20;display:grid;gap:8px;max-width:min(420px,calc(100vw - 40px))}
|
|
70
|
+
.toast{background:var(--inv);color:var(--inv-ink);border-radius:12px;padding:12px 16px;font-size:13.5px;box-shadow:0 12px 32px -8px rgba(0,0,0,.35);animation:rise .2s ease-out} .toast.err{background:var(--bad);color:#fff}
|
|
71
|
+
@keyframes rise{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
|
|
72
|
+
dialog{border:1px solid var(--line);border-radius:16px;padding:0;max-height:calc(100dvh - 32px);overflow:auto;background:var(--surface);color:var(--ink);width:min(520px,calc(100vw - 32px));box-shadow:0 30px 80px -20px rgba(0,0,0,.45)}
|
|
73
|
+
dialog::backdrop{background:rgba(0,0,0,.45);backdrop-filter:blur(3px)}
|
|
74
|
+
.dlg{padding:24px;display:grid;gap:16px}
|
|
75
|
+
.flow{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;counter-reset:step} .flow>div{border:1px solid var(--line);border-radius:10px;padding:10px;display:grid;gap:4px;align-content:start;position:relative} .flow>div::before{counter-increment:step;content:counter(step);font-size:11px;color:var(--ink2)} .flow b{font-size:13px} .flow span{font-size:12px;color:var(--ink2)}
|
|
76
|
+
.opts{display:grid;gap:8px;margin:0;padding:0;list-style:none} .opts li{display:grid;grid-template-columns:150px 1fr;gap:12px;font-size:13px} .opts li b{font-weight:600}
|
|
77
|
+
@media (max-width:640px){.flow{grid-template-columns:1fr}.opts li{grid-template-columns:1fr;gap:2px}}
|
|
78
|
+
.notes{margin:0;padding-left:18px;display:grid;gap:6px;color:var(--ink2)}
|
|
79
|
+
.progress{height:4px;border-radius:4px;background:var(--sunk);overflow:hidden} .progress i{display:block;height:100%;width:35%;background:var(--ink);border-radius:4px;animation:slide 1.2s ease-in-out infinite}
|
|
80
|
+
@keyframes slide{0%{transform:translateX(-100%)}100%{transform:translateX(300%)}}
|
|
81
|
+
.kbd{font:11px ui-monospace,monospace;border:1px solid var(--line);border-bottom-width:2px;border-radius:5px;padding:0 5px;color:var(--muted)}
|
|
82
|
+
</style></head><body><main>
|
|
83
|
+
<div class="gate" id="signin-wrap"><form id="signin" class="card" hidden><div class="mark">${lock()}</div><h2>Sign in to Secure</h2><p class="muted">Type your passphrase. Your agent never knows it, so only you can approve.</p>
|
|
84
|
+
<input id="pass" type="password" autocomplete="current-password" aria-label="Secure passphrase" placeholder="Passphrase"><button class="primary" type="submit">Sign in</button><p id="signin-err" class="warn" hidden></p></form>
|
|
85
|
+
<div id="setup" class="card" hidden><div class="mark">${lock()}</div><h2>Set up your passkey</h2>
|
|
86
|
+
<p class="muted">Every approval needs a touch of your passkey: Touch ID on a Mac, Windows Hello on a PC, or your phone. Your agent can't touch it, so it can't approve anything, even if it learns your passphrase.</p>
|
|
87
|
+
<button class="primary" id="pk-setup">Set up passkey</button><p id="pk-err" class="warn" hidden></p></div></div>
|
|
88
|
+
|
|
89
|
+
<div id="app" hidden>
|
|
90
|
+
<header class="top"><div class="bar"><div class="mark">${lock()}</div><h1 id="me">Secure</h1><span class="pill" id="online"><span class="dot"></span><span>No computers</span></span><span class="grow"></span>
|
|
91
|
+
<button class="ghost" id="update-open" hidden>Update available</button><button class="ghost" id="out">Sign out</button></div>
|
|
92
|
+
<nav class="tabs" role="tablist"><button role="tab" data-tab="inbox" aria-selected="true">Inbox<span class="count" id="pending-count"></span></button><button role="tab" data-tab="computers">Computers</button><button role="tab" data-tab="history">History</button><button role="tab" data-tab="brain">Brain</button><button role="tab" data-tab="settings">Settings</button></nav>
|
|
93
|
+
<div class="beam"></div></header>
|
|
94
|
+
|
|
95
|
+
<section class="page" data-page="inbox"><div class="head"><h2>Inbox</h2><p class="muted">Requests that wait for your yes. Nothing reaches your agent, and nothing leaves this computer, until you approve it here.</p></div><div id="pending" class="page" style="padding:0"></div></section>
|
|
96
|
+
|
|
97
|
+
<section class="page" data-page="computers" hidden><div class="head"><h2>Computers</h2><p class="muted">The computers paired with this one, what they may ask for, and how far your agent may reach for them.</p></div><div id="peers" class="page" style="padding:0"></div></section>
|
|
98
|
+
|
|
99
|
+
<section class="page" data-page="history" hidden><div class="head"><h2>History</h2><p class="muted">Every request asked of this computer and every answer it gave, newest first. Click a row for the details and your agent's log.</p></div>
|
|
100
|
+
<div class="card"><div class="row"><select id="hist-peer" aria-label="Show history for"><option value="">All computers</option></select><span class="grow"></span><button class="ghost" id="hist-refresh">Refresh</button></div><div id="history" class="hist"></div></div></section>
|
|
101
|
+
|
|
102
|
+
<section class="page" data-page="brain" hidden><div class="head"><h2>Brain</h2><p class="muted">Decisions your agent saved, with why and sources. Shared ones can be searched by paired computers, even while this one is off.</p></div><div id="brain" class="page" style="padding:0"></div></section>
|
|
103
|
+
|
|
104
|
+
<section class="page" data-page="settings" hidden><div class="head"><h2>Settings</h2><p class="muted">How your agent is started, updates, and your passkeys.</p></div>
|
|
105
|
+
<div class="card"><div class="row"><div class="grow"><h3>Wake your agent</h3><p class="muted small">Starts your agent by itself for a question or task you approved, so it answers without you opening it. Questions never change files.</p></div><button class="ghost" id="wake-help">How this works</button></div>
|
|
106
|
+
<p id="wake"></p>
|
|
107
|
+
<div class="row"><select id="wake-preset" aria-label="Agent to wake"><option value="off">Off: I tell my agent myself</option><option value="claude">Claude Code</option><option value="codex">Codex</option><option value="custom">Other agent (type its command)</option></select>
|
|
108
|
+
<input id="wake-cwd" class="field" placeholder="Folder where tasks may make changes, e.g. /Users/me/project" aria-label="Folder where tasks may make changes"></div>
|
|
109
|
+
<input id="wake-line" class="field" hidden placeholder="Command that runs your agent once, reading the prompt from stdin, e.g. gemini -p" aria-label="Agent command">
|
|
110
|
+
<p id="wake-last" class="small muted" hidden></p>
|
|
111
|
+
<div class="row"><label class="row small muted"><input type="checkbox" id="wake-cmds"> Tasks may run commands</label><label class="row small muted"><input type="checkbox" id="wake-net"> Tasks may use the internet</label>
|
|
112
|
+
<label class="row small muted" style="flex-wrap:nowrap">Stop a run after <input id="wake-min" class="field" type="number" min="1" max="10080" style="width:90px;flex:none;min-width:0" aria-label="Time limit in minutes"> minutes</label><span class="grow"></span><button class="primary" id="wake-save">Save</button></div></div>
|
|
113
|
+
<div class="card"><div><h3>Largest file</h3><p class="muted small">The biggest file this computer sends or accepts. A file moves only up to the smaller limit of the two computers, so the other person may need to raise theirs too: they are told when you raise yours.</p></div>
|
|
114
|
+
<div class="row"><input id="fl-mb" class="field" type="number" min="1" style="width:140px;flex:none;min-width:0" aria-label="Largest file in MB"><span class="muted small">MB</span><span class="muted small" id="fl-now"></span><span class="grow"></span><button class="primary" id="fl-save">Save</button></div></div>
|
|
115
|
+
<div class="card"><div class="row"><div class="grow"><h3>Updates</h3><p class="muted small" id="version"></p></div><button id="update-check">Check for updates</button></div></div>
|
|
116
|
+
<div class="card"><div class="row"><div class="grow"><h3>Passkeys</h3><p class="muted small" id="pk-count"></p></div><button id="pk-add">Add another passkey</button></div></div>
|
|
117
|
+
<div class="card"><div class="row"><div class="grow"><h3>Audit log</h3><p class="muted small" id="audit">Every decision and every byte in or out is logged, chained so an edit shows.</p></div><button id="audit-check">Check it</button></div></div>
|
|
118
|
+
</section>
|
|
119
|
+
</div>
|
|
120
|
+
</main>
|
|
121
|
+
<div class="toasts" id="toasts" aria-live="polite"></div>
|
|
122
|
+
<dialog id="wake-dlg" style="width:min(760px,calc(100vw - 32px))"><div class="dlg"><div class="row"><div class="mark">${lock()}</div><div class="grow"><h3>How waking your agent works</h3><p class="muted small">What happens when another computer asks your agent something.</p></div></div>
|
|
123
|
+
<div class="flow"><div><b>Request arrives</b><span>A question or a task from a paired computer.</span></div><div><b>You approve</b><span>With your passkey. Automatic questions skip this unless Secure flags them.</span></div><div><b>Secure starts your agent</b><span>Claude Code or Codex, in the background, inside its sandbox.</span></div><div><b>It does the work</b><span>Questions only read. Tasks change files only inside your folder.</span></div><div><b>Result goes back</b><span>An answer with sources, or a signed task result.</span></div></div>
|
|
124
|
+
<p class="sub" style="margin:0">The settings</p>
|
|
125
|
+
<ul class="opts"><li><b>Off</b><span>Nothing starts by itself. Approved requests wait until you open your agent and say "check Secure".</span></li>
|
|
126
|
+
<li><b>Claude Code / Codex</b><span>Secure starts that agent for you. What it may read is set per computer (Computers, then Reach).</span></li>
|
|
127
|
+
<li><b>Folder</b><span>Where tasks may change files, including every folder inside it. Nothing outside it can be changed. Questions never change files.</span></li>
|
|
128
|
+
<li><b>Tasks may run commands</b><span>Lets a task run tests, builds and scripts. Off: it can only read and edit files.</span></li>
|
|
129
|
+
<li><b>Tasks may use the internet</b><span>Lets a task open web pages and call online services.</span></li>
|
|
130
|
+
<li><b>Stop a run after</b><span>The longest one run may take before Secure stops it.</span></li></ul>
|
|
131
|
+
<div class="row"><span class="grow"></span><button class="primary" id="wake-dlg-close">Got it</button></div></div></dialog>
|
|
132
|
+
<dialog id="update-dlg"><div class="dlg"><div class="row"><div class="mark">${lock()}</div><div class="grow"><h3 id="dlg-title">Update</h3><p class="muted small" id="dlg-sub"></p></div></div>
|
|
133
|
+
<div id="dlg-notes-wrap"><p class="sub" style="margin-bottom:8px">What's new</p><ul class="notes" id="dlg-notes"></ul></div>
|
|
134
|
+
<p class="small"><a id="dlg-link" target="_blank" rel="noopener">See the exact code changes on npm</a></p>
|
|
135
|
+
<div id="dlg-progress" hidden><div class="progress"><i></i></div><p class="muted small" id="dlg-step" style="margin-top:8px"></p></div>
|
|
136
|
+
<p class="warn" id="dlg-err" hidden></p>
|
|
137
|
+
<div class="row"><span class="grow"></span><button class="ghost" id="dlg-close">Later</button><button class="primary" id="dlg-install">Install with passkey</button></div></div></dialog>
|
|
138
|
+
<script>
|
|
139
|
+
const NL = String.fromCharCode(10);
|
|
140
|
+
const api = (p, body) => fetch(p, { method: body ? 'POST' : 'GET', credentials: 'same-origin', headers: { 'x-secure-human': '1', 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }).then(r => r.json());
|
|
141
|
+
const $ = (id) => document.getElementById(id);
|
|
142
|
+
const el = (tag, props = {}, ...kids) => { const e = document.createElement(tag); Object.assign(e, props); for (const k of kids) if (k != null) e.append(k); return e; };
|
|
143
|
+
const signin = $('signin'), app = $('app');
|
|
144
|
+
function toast(msg, err) { const t = el('div', { className: 'toast' + (err ? ' err' : ''), role: err ? 'alert' : 'status', textContent: msg }); $('toasts').append(t); setTimeout(() => t.remove(), err ? 7000 : 4500); }
|
|
145
|
+
const showErr = (id, msg) => { const e = $(id); e.textContent = msg || ''; e.hidden = !msg; };
|
|
146
|
+
signin.onsubmit = (e) => { e.preventDefault(); const p = $('pass'); api('/human/login', { passphrase: p.value }).then(r => { p.value = ''; showErr('signin-err', r.error); if (!r.error) load(); }); };
|
|
147
|
+
$('out').onclick = () => api('/human/logout', {}).then(load);
|
|
148
|
+
|
|
149
|
+
// Tabs
|
|
150
|
+
let tab = 'inbox';
|
|
151
|
+
try { tab = localStorage.getItem('secure-tab') || 'inbox'; } catch (e) {}
|
|
152
|
+
function show(t) { tab = t; try { localStorage.setItem('secure-tab', t); } catch (e) {}
|
|
153
|
+
if (t === 'settings' && !app.hidden) { let seen = true; try { seen = !!localStorage.getItem('secure-wake-explained'); localStorage.setItem('secure-wake-explained', '1'); } catch (e) {} if (!seen) $('wake-dlg').showModal(); } for (const b of document.querySelectorAll('nav.tabs button')) b.setAttribute('aria-selected', String(b.dataset.tab === t)); for (const s of document.querySelectorAll('[data-page]')) s.hidden = s.dataset.page !== t; if (t === 'history') loadHistory(); }
|
|
154
|
+
for (const b of document.querySelectorAll('nav.tabs button')) b.onclick = () => show(b.dataset.tab);
|
|
155
|
+
$('wake-help').onclick = () => $('wake-dlg').showModal(); $('wake-dlg-close').onclick = () => $('wake-dlg').close();
|
|
156
|
+
|
|
157
|
+
// Passkeys: the server fixes exactly what will happen, then your touch confirms it.
|
|
158
|
+
const b64 = (s) => Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/') + '==='.slice((s.length + 3) % 4)), c => c.charCodeAt(0));
|
|
159
|
+
const u64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))).split('+').join('-').split('/').join('_').replace(/=+$/, '');
|
|
160
|
+
async function createPasskey(o) {
|
|
161
|
+
const c = await navigator.credentials.create({ publicKey: { ...o, challenge: b64(o.challenge), user: { ...o.user, id: b64(o.user.id) }, excludeCredentials: (o.excludeCredentials || []).map(x => ({ ...x, id: b64(x.id) })) } });
|
|
162
|
+
return { id: c.id, rawId: u64(c.rawId), type: c.type, clientExtensionResults: c.getClientExtensionResults(), authenticatorAttachment: c.authenticatorAttachment || undefined,
|
|
163
|
+
response: { clientDataJSON: u64(c.response.clientDataJSON), attestationObject: u64(c.response.attestationObject), transports: c.response.getTransports ? c.response.getTransports() : [] } };
|
|
164
|
+
}
|
|
165
|
+
async function touch(o) {
|
|
166
|
+
const c = await navigator.credentials.get({ publicKey: { ...o, challenge: b64(o.challenge), allowCredentials: (o.allowCredentials || []).map(x => ({ ...x, id: b64(x.id) })) } });
|
|
167
|
+
return { id: c.id, rawId: u64(c.rawId), type: c.type, clientExtensionResults: c.getClientExtensionResults(), authenticatorAttachment: c.authenticatorAttachment || undefined,
|
|
168
|
+
response: { clientDataJSON: u64(c.response.clientDataJSON), authenticatorData: u64(c.response.authenticatorData), signature: u64(c.response.signature), userHandle: c.response.userHandle ? u64(c.response.userHandle) : undefined } };
|
|
169
|
+
}
|
|
170
|
+
async function act(action, body, quiet) {
|
|
171
|
+
try {
|
|
172
|
+
const ch = await api('/human/challenge', { action, body });
|
|
173
|
+
if (ch.error) throw new Error(ch.error);
|
|
174
|
+
const r = await api('/human/confirm', { id: ch.id, response: await touch(ch.options) });
|
|
175
|
+
if (r && r.error) throw new Error(r.error);
|
|
176
|
+
return r;
|
|
177
|
+
} catch (e) { const m = e.name === 'NotAllowedError' ? 'Passkey cancelled. Nothing changed.' : (e.message || String(e)); if (!quiet) toast(m, true); return { error: m }; }
|
|
178
|
+
}
|
|
179
|
+
async function register(start) {
|
|
180
|
+
const o = await start();
|
|
181
|
+
if (o.error) throw new Error(o.error);
|
|
182
|
+
const r = await api('/human/passkey/register', { id: o.id, response: await createPasskey(o.options) });
|
|
183
|
+
if (r.error) throw new Error(r.error);
|
|
184
|
+
}
|
|
185
|
+
$('pk-setup').onclick = () => register(() => api('/human/passkey/options', {})).then(load, (e) => showErr('pk-err', e.message));
|
|
186
|
+
$('pk-add').onclick = () => register(() => act('add-passkey', {})).then(() => { toast('Passkey added.'); load(); }, (e) => toast(e.message, true));
|
|
187
|
+
$('audit-check').onclick = () => api('/human/audit').then(r => { $('audit').textContent = r.brokenAt ? 'Line ' + r.brokenAt + ' of ' + r.lines + ' fails the check: ' + r.reason + '.' : 'All ' + r.lines + ' lines check out: nothing was edited or removed.'; });
|
|
188
|
+
|
|
189
|
+
// Updates: a pop-up with what's new, the code changes, and the install progress.
|
|
190
|
+
const dlg = $('update-dlg');
|
|
191
|
+
function openUpdate() {
|
|
192
|
+
const u = lastState.update_available; if (!u) return;
|
|
193
|
+
$('dlg-title').textContent = 'Secure ' + u.latest; $('dlg-sub').textContent = 'You have ' + u.current + '. Nothing installs without your passkey.';
|
|
194
|
+
$('dlg-notes').replaceChildren(...(u.notes && u.notes.length ? u.notes : ['See the code changes for details.']).map(n => el('li', { textContent: n })));
|
|
195
|
+
$('dlg-link').href = u.changes; $('dlg-progress').hidden = true; showErr('dlg-err', ''); $('dlg-install').disabled = false; $('dlg-close').textContent = 'Later';
|
|
196
|
+
if (!dlg.open) dlg.showModal();
|
|
197
|
+
}
|
|
198
|
+
$('update-open').onclick = openUpdate;
|
|
199
|
+
$('dlg-close').onclick = () => dlg.close();
|
|
200
|
+
let restarting = false;
|
|
201
|
+
$('dlg-install').onclick = async () => {
|
|
202
|
+
const v = lastState.update_available.latest;
|
|
203
|
+
$('dlg-install').disabled = true; showErr('dlg-err', ''); $('dlg-progress').hidden = false; $('dlg-step').textContent = 'Waiting for your passkey…';
|
|
204
|
+
const p = act('update', { version: v }, true);
|
|
205
|
+
setTimeout(() => { if (!$('dlg-progress').hidden && !restarting) $('dlg-step').textContent = 'Downloading and installing ' + v + '…'; }, 1500);
|
|
206
|
+
const r = await p;
|
|
207
|
+
if (r.error) { $('dlg-progress').hidden = true; showErr('dlg-err', r.error); $('dlg-install').disabled = false; return; }
|
|
208
|
+
restarting = true; $('dlg-step').textContent = 'Installed. Restarting Secure in this window…'; $('dlg-close').textContent = 'Close';
|
|
209
|
+
};
|
|
210
|
+
setInterval(() => { if (restarting) api('/human/state').then((s) => { if (s.version && s.version !== lastState.version) { $('dlg-step').textContent = 'Done. Reloading…'; setTimeout(() => location.reload(), 600); } }, () => {}); }, 1500);
|
|
211
|
+
$('update-check').onclick = () => { const v = $('version'); v.textContent = 'Checking npm…'; api('/human/check-update', {}).then((r) => { if (r.update_available) { lastState.update_available = r.update_available; v.textContent = 'Secure ' + r.update_available.latest + ' is available.'; openUpdate(); } else v.textContent = 'You have the latest version (' + (lastState.version || '') + ').'; load(); }, () => { v.textContent = 'Could not reach npm. Try again later.'; }); };
|
|
212
|
+
|
|
213
|
+
// Wake
|
|
214
|
+
$('wake-preset').onchange = (e) => { $('wake-line').hidden = e.target.value !== 'custom'; };
|
|
215
|
+
$('fl-save').onclick = () => act('file-limit', { mb: Number($('fl-mb').value) }).then(r => { if (!r.error) toast('Saved. Paired computers are told your new limit.'); $('fl-mb').value = ''; load(); });
|
|
216
|
+
$('wake-save').onclick = () => { const preset = $('wake-preset').value; act('wake', { preset, cwd: $('wake-cwd').value || '/', line: $('wake-line').value, allowCommands: $('wake-cmds').checked, internet: $('wake-net').checked, timeoutMinutes: $('wake-min').value }).then(r => { if (!r.error) toast(preset === 'off' ? 'Your agent will not be woken.' : 'Saved. Your agent wakes for what you approve.'); load(); }); };
|
|
217
|
+
|
|
218
|
+
const LABEL = { pair: 'pairing', ask: 'question', reply: 'answer', file_request: 'file request', file: 'file', task: 'task', task_update: 'task result', brain_query: 'brain search', brain_result: 'brain results', task_reply: 'answer to task question', notice: 'notice' };
|
|
219
|
+
const kb = (n) => n > 1048576 ? (n / 1048576).toFixed(1) + ' MB' : (n / 1024).toFixed(1) + ' KB';
|
|
220
|
+
function bodyOf(i) {
|
|
221
|
+
const d = i.data || {};
|
|
222
|
+
if (i.kind === 'brain_result') return JSON.stringify(d.results, null, 2);
|
|
223
|
+
if (i.kind === 'file' && i.direction === 'in') return i.text + NL + 'SHA-256: ' + d.sha256;
|
|
224
|
+
if (i.kind === 'file') return 'File: ' + d.abs + NL + 'Size: ' + kb(d.bytes) + NL + 'SHA-256: ' + d.sha256 + (d.note ? NL + 'Note: ' + d.note : '');
|
|
225
|
+
return i.text + (d.sources && d.sources.length ? NL + NL + 'Sources:' + NL + d.sources.join(NL) : '') + (d.receipt ? NL + NL + 'Result files:' + NL + d.receipt.artifacts.map(a => a.path + ' (' + kb(a.bytes) + ')').join(NL) : '');
|
|
226
|
+
}
|
|
227
|
+
function pendingCard(i) {
|
|
228
|
+
const d = i.data || {};
|
|
229
|
+
const head = i.kind === 'pair' && i.direction === 'out' ? 'Your agent wants to join another computer'
|
|
230
|
+
: i.kind === 'pair' ? i.peerName + ' wants to pair with this computer'
|
|
231
|
+
: i.kind === 'file' && i.direction === 'in' ? i.peerName + ' sent your agent a file'
|
|
232
|
+
: i.kind === 'reply' ? 'An answer from ' + i.peerName + ' was held before your agent reads it'
|
|
233
|
+
: i.kind === 'task_update' ? 'A task result from ' + i.peerName + ' was held before your agent reads it'
|
|
234
|
+
: i.kind === 'brain_result' ? 'Decisions from ' + i.peerName + ' were held before your agent reads them'
|
|
235
|
+
: i.kind === 'file' ? 'Send a file to ' + i.peerName + (d.requestId ? ' (they asked for it)' : '')
|
|
236
|
+
: i.kind === 'task' ? i.peerName + ' wants your agent to do a task'
|
|
237
|
+
: i.kind === 'brain_query' ? i.peerName + ' wants to search your shared decisions'
|
|
238
|
+
: i.direction === 'out' ? 'Your agent wants to send this ' + (LABEL[i.kind] || 'message') + ' to ' + i.peerName
|
|
239
|
+
: i.peerName + ' asks your agent';
|
|
240
|
+
const note = i.kind === 'pair' ? 'Read this safety number to the other person by phone or in person. It must match on both screens.'
|
|
241
|
+
: i.kind === 'file' && i.direction === 'in' ? 'Checked against its checksum. Saved as not executable and marked as downloaded.'
|
|
242
|
+
: i.kind === 'file' ? 'Only this one file leaves, exactly as checked. It is re-checked before sending.'
|
|
243
|
+
: i.kind === 'task' ? 'Tasks can change files, so they always wait for you. If you approve, your agent does this on this computer.'
|
|
244
|
+
: i.kind === 'brain_query' ? 'Only decisions you marked as shared can be returned.'
|
|
245
|
+
: i.direction === 'out' ? 'Only the text below leaves this computer.'
|
|
246
|
+
: 'This reaches your agent as a request from another computer, never as your instruction.';
|
|
247
|
+
const yes = i.kind === 'pair' ? (i.direction === 'out' ? 'Join' : 'Pair') : i.kind === 'file' && i.direction === 'in' ? 'Give it to my agent' : i.direction === 'in' && ['reply', 'task_update', 'brain_result'].includes(i.kind) ? 'Let my agent read it' : i.kind === 'task' ? 'Approve task' : i.kind === 'brain_query' ? 'Allow search' : i.direction === 'out' ? 'Send' : 'Let my agent answer';
|
|
248
|
+
const warnings = d.warnings || [];
|
|
249
|
+
const ok = el('button', { className: 'primary', textContent: yes + ' · passkey', onclick: () => act('approve', { id: i.id }).then((r) => { if (r && !r.error) { toast('Approved.'); if (i.direction === 'in' && (i.kind === 'ask' || i.kind === 'task') && !lastState.wake) toast('Your agent is not woken automatically: tell it "check Secure".'); } load(); }) });
|
|
250
|
+
const checks = [];
|
|
251
|
+
if (i.kind === 'pair') { ok.disabled = true; const c = el('input', { type: 'checkbox', onchange: () => { ok.disabled = !c.checked; } }); checks.push(el('label', { className: 'row small' }, c, 'I compared the safety number with the other person and it matches')); }
|
|
252
|
+
if (warnings.length) { ok.disabled = true; const c = el('input', { type: 'checkbox', onchange: () => { ok.disabled = !c.checked; } }); checks.push(el('label', { className: 'row small' }, c, 'I reviewed it and want to go ahead')); }
|
|
253
|
+
return el('div', { className: 'card ' + (warnings.length ? 'alert' : 'hold') }, el('div', { className: 'title', textContent: head }),
|
|
254
|
+
...warnings.map(w => el('p', { className: 'warn', textContent: 'This ' + w + '. It needs your review.' })),
|
|
255
|
+
el('pre', { textContent: bodyOf(i) }), el('p', { className: 'muted small', textContent: note }), ...checks,
|
|
256
|
+
el('div', { className: 'row' }, ok, el('button', { className: 'danger', textContent: 'Deny', onclick: () => api('/human/deny', { id: i.id }).then(load) })));
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const RULES = [
|
|
260
|
+
['ask', 'Questions to my agent', 'Their questions reach your agent. Requests for changes or secrets always wait for you.'],
|
|
261
|
+
['send', 'What my agent sends', 'Answers and messages your agent sends them. Anything that looks like a secret waits for you.'],
|
|
262
|
+
['files', 'Files they may take', 'Files they request from what your agent may read. Secret-looking files wait for you.'],
|
|
263
|
+
['tasks', 'Tasks for my agent', 'Work that can change files. Always waits for your review.'],
|
|
264
|
+
['brain', 'Search my shared decisions', 'They search decisions you marked as shared. Uses no AI.'],
|
|
265
|
+
];
|
|
266
|
+
function seg(options, value, onpick) {
|
|
267
|
+
const s = el('div', { className: 'seg', role: 'group' });
|
|
268
|
+
for (const [v, t] of options) s.append(el('button', { textContent: t, ariaPressed: String(v === value), onclick: () => { if (v !== value) onpick(v); } }));
|
|
269
|
+
return s;
|
|
270
|
+
}
|
|
271
|
+
function peerCard(p) {
|
|
272
|
+
const folder = el('input', { placeholder: '/full/path/to/folder', ariaLabel: 'Folder to share with ' + p.name, className: 'field' });
|
|
273
|
+
const setRule = (k) => (v) => act('rule', { peer: p.publicKey, key: k, rule: v }).then(r => { if (!r.error) toast('Rule changed.'); load(true); });
|
|
274
|
+
const setAccess = (reach, tools) => act('access', { peer: p.publicKey, reach, tools }).then(r => { if (!r.error) toast('Access changed.'); load(true); });
|
|
275
|
+
return el('div', { className: 'card' },
|
|
276
|
+
el('div', { className: 'row' }, el('span', { className: 'dot' + (p.online ? ' on' : '') }), el('h3', { textContent: p.name }), el('span', { className: 'muted small', textContent: p.online ? 'Online' : 'Offline' }),
|
|
277
|
+
el('span', { className: 'muted small', textContent: p.brainCopy ? '· their decisions copied ' + new Date(p.brainCopy).toLocaleString() : '' }), el('span', { className: 'grow' }),
|
|
278
|
+
el('button', { className: 'ghost danger', textContent: 'Unpair', onclick: () => act('unpair', { peer: p.publicKey }).then(load) })),
|
|
279
|
+
el('p', { className: 'muted small', textContent: 'Files up to ' + Math.round(p.fileLimit.between / 1048576) + ' MB can move between you' + (p.fileLimit.theirs === null ? '.' : ': the smaller of your limit (' + Math.round(p.fileLimit.mine / 1048576) + ' MB) and theirs (' + Math.round(p.fileLimit.theirs / 1048576) + ' MB). Change yours in Settings.') }),
|
|
280
|
+
el('div', {}, el('p', { className: 'sub', textContent: 'Rules' }), el('div', { className: 'rules' }, ...RULES.map(([k, t, help]) => el('div', { className: 'rule' },
|
|
281
|
+
el('div', { className: 'what' }, el('span', { textContent: t }), el('span', { className: 'muted small', textContent: help })),
|
|
282
|
+
seg(k === 'tasks' ? [['ask', 'Ask me'], ['never', 'Never']] : [['auto', 'Automatic'], ['ask', 'Ask me'], ['never', 'Never']], p.permissions[k], setRule(k)))))),
|
|
283
|
+
el('div', {}, el('p', { className: 'sub', textContent: 'Your agent\\'s reach for ' + p.name }), el('div', { className: 'rules' },
|
|
284
|
+
el('div', { className: 'rule' }, el('div', { className: 'what' }, el('span', { textContent: 'What it may read' }), el('span', { className: 'muted small', textContent: p.reach === 'computer' ? 'Any file on this computer, except Secure\\'s own keys.' : 'Only the folders below. Other folders are blocked by the agent\\'s sandbox.' })),
|
|
285
|
+
seg([['folders', 'Shared folders'], ['computer', 'Whole computer']], p.reach, (v) => setAccess(v, p.tools))),
|
|
286
|
+
el('div', { className: 'rule' }, el('div', { className: 'what' }, el('span', { textContent: 'Tools it may use' }), el('span', { className: 'muted small', textContent: p.tools === 'all' ? 'All its tools and connections (logs, databases, trackers) to answer.' : 'File reading only.' })),
|
|
287
|
+
seg([['files', 'Files only'], ['all', 'All its tools']], p.tools, (v) => setAccess(p.reach, v))))),
|
|
288
|
+
el('div', { className: 'folders' }, el('p', { className: 'sub', textContent: 'Shared folders' }),
|
|
289
|
+
...(p.shared.length ? p.shared.map(f => el('div', { className: 'row', style: 'flex-wrap:nowrap' }, el('code', { textContent: f, title: f, style: 'min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }), el('span', { className: 'grow' }), el('button', { className: 'ghost', textContent: 'Stop sharing', onclick: () => act('share', { peer: p.publicKey, folder: f, remove: true }).then(load) }))) : [el('p', { className: 'muted small', textContent: p.reach === 'computer' ? 'None needed: your agent may read the whole computer for them.' : 'None yet. Share a folder so your agent can answer from it.' })]),
|
|
290
|
+
el('div', { className: 'row' }, folder, el('button', { textContent: 'Share folder', onclick: () => folder.value && act('share', { peer: p.publicKey, folder: folder.value }).then((r) => { if (!r.error) toast('Folder shared.'); load(true); }) }))));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function histRow(i) {
|
|
294
|
+
const d = i.data || {};
|
|
295
|
+
const sum = el('span', { className: 'sum' }, el('strong', { textContent: (LABEL[i.kind] || i.kind) + ' ' }), el('span', { className: 'muted', textContent: (i.direction === 'in' ? 'from ' : 'to ') + i.peerName + ' · ' + i.text.split(NL)[0].slice(0, 140) }));
|
|
296
|
+
const row = el('div', { className: 'hrow', tabIndex: 0, role: 'button' }, el('span', { className: 't', textContent: new Date(i.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }), el('span', { className: 'arrow', textContent: i.direction === 'in' ? '↓' : '↑', title: i.direction === 'in' ? 'Received' : 'Sent' }), sum, el('span', { className: 'status ' + (d.remote === 'held' && i.status === 'sent' ? 'awaiting_human' : i.status), textContent: i.status === 'sent' && d.remote ? (d.remote === 'held' ? 'waiting for them' : 'approved by them') : i.status.replace('_', ' ') }));
|
|
297
|
+
const detail = el('div', { className: 'detail', hidden: true });
|
|
298
|
+
const open = () => {
|
|
299
|
+
if (!detail.hidden) { detail.hidden = true; return; }
|
|
300
|
+
const log = el('pre', { hidden: true });
|
|
301
|
+
detail.replaceChildren(el('p', { className: 'muted small', textContent: new Date(i.createdAt).toLocaleString() + (i.note ? ' · ' + i.note : '') + (d.checked ? ' · checked: ' + d.checked : '') }),
|
|
302
|
+
...(d.warnings || []).map(w => el('p', { className: 'warn', textContent: 'This ' + w + '.' })), el('pre', { textContent: bodyOf(i) }),
|
|
303
|
+
...(i.hasLog ? [el('div', { className: 'row' }, el('button', { textContent: 'Show what your agent did', onclick: () => api('/human/agent-log?id=' + i.id).then(r => { log.textContent = r.log; log.hidden = false; }) }))] : []), log);
|
|
304
|
+
detail.hidden = false;
|
|
305
|
+
};
|
|
306
|
+
row.onclick = open; row.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); } };
|
|
307
|
+
return [row, detail];
|
|
308
|
+
}
|
|
309
|
+
function loadHistory() {
|
|
310
|
+
api('/human/history?peer=' + encodeURIComponent($('hist-peer').value)).then(list => { if (list.error) return; $('history').replaceChildren(...(list.length ? list.flatMap(histRow) : [el('p', { className: 'empty', textContent: 'Nothing yet. Requests and answers appear here.' })])); });
|
|
311
|
+
}
|
|
312
|
+
$('hist-peer').onchange = loadHistory; $('hist-refresh').onclick = loadHistory;
|
|
313
|
+
|
|
314
|
+
function brainCards(list) {
|
|
315
|
+
if (!list.length) return [el('p', { className: 'empty', textContent: 'No decisions yet. Ask your agent to record one, for example: "record a decision: we use Postgres because…"' })];
|
|
316
|
+
return list.slice().reverse().map(d => el('div', { className: 'card' }, el('div', { className: 'row' }, el('h3', { className: 'grow', textContent: d.title }),
|
|
317
|
+
el('label', { className: 'row small muted' }, el('input', { type: 'checkbox', checked: d.shared, ariaLabel: 'Share ' + d.title, onchange: (e) => act('decision-shared', { id: d.id, shared: e.target.checked }).then(load) }), 'Shared')),
|
|
318
|
+
el('p', { textContent: d.decision }), d.why ? el('p', { className: 'muted small', textContent: 'Why: ' + d.why }) : null, (d.sources || []).length ? el('p', { className: 'mono muted', textContent: d.sources.join(' · ') }) : null));
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
let pendingSig = '', peersSig = '', brainSig = '', wakeSig = '', lastState = {}, announced = '';
|
|
322
|
+
async function load(force) {
|
|
323
|
+
const s = await api('/human/state');
|
|
324
|
+
signin.hidden = s.error !== 'signin';
|
|
325
|
+
const setup = $('setup');
|
|
326
|
+
setup.hidden = !!s.error || s.passkeys > 0; app.hidden = !!s.error || !s.passkeys;
|
|
327
|
+
$('signin-wrap').hidden = !app.hidden;
|
|
328
|
+
if (s.error) { if (s.error !== 'signin') showErr('signin-err', s.error); return; }
|
|
329
|
+
lastState = s;
|
|
330
|
+
$('pk-count').textContent = s.passkeys + ' passkey(s). Adding another needs a touch of an existing one.';
|
|
331
|
+
const vEl = $('version'); if (!/Checking|available|latest|reach/.test(vEl.textContent)) vEl.textContent = 'This computer runs Secure ' + s.version + '. New versions install only with your passkey.';
|
|
332
|
+
$('update-open').hidden = !s.update_available;
|
|
333
|
+
if (s.update_available && announced !== s.update_available.latest) { announced = s.update_available.latest; $('update-open').textContent = 'Update to ' + announced; }
|
|
334
|
+
const on = s.peers.filter(p => p.online).length;
|
|
335
|
+
$('online').replaceChildren(el('span', { className: 'dot' + (on ? ' on' : '') }), el('span', { textContent: s.peers.length ? on + ' of ' + s.peers.length + ' online' : 'No computers yet' }));
|
|
336
|
+
$('me').textContent = s.name;
|
|
337
|
+
const wl = $('wake-last'); wl.hidden = !s.wakeLast;
|
|
338
|
+
if (s.wakeLast) { wl.textContent = 'Last run ' + new Date(s.wakeLast.at).toLocaleTimeString() + ': ' + s.wakeLast.message; wl.style.color = s.wakeLast.ok ? '' : 'var(--bad)'; }
|
|
339
|
+
$('wake').textContent = s.wake ? 'Wakes ' + s.wake + '.' : 'Off. Approved requests wait until you tell your agent "check Secure".';
|
|
340
|
+
if (document.activeElement !== $('wake-preset') && document.activeElement !== $('wake-cwd') && !$('wake-cwd').value && s.wakePreset) $('wake-preset').value = s.wakePreset;
|
|
341
|
+
// Refill only when the saved values change, so a box you just ticked isn't reset before you save.
|
|
342
|
+
const ws = JSON.stringify([s.wakeMinutes, s.wakeInternet, s.wakeCommands]);
|
|
343
|
+
if (ws !== wakeSig) { wakeSig = ws; $('wake-min').value = s.wakeMinutes; $('wake-net').checked = s.wakeInternet; $('wake-cmds').checked = s.wakeCommands; }
|
|
344
|
+
$('fl-mb').max = s.fileLimitMaxMB; $('fl-now').textContent = 'Now ' + Math.round(s.maxFileBytes / 1048576) + ' MB';
|
|
345
|
+
// Redraw only what changed, so a half-read card or a typed path isn't reset every refresh.
|
|
346
|
+
// Questions your agent is still checking stay out of the Inbox; they show up only if they need you.
|
|
347
|
+
const waiting = s.pending.filter(i => !(i.data || {}).checking), checking = s.pending.length - waiting.length;
|
|
348
|
+
const sig = JSON.stringify([checking, s.notices.map(n => n.id), waiting.map(i => [i.id, i.status, ((i.data || {}).warnings || []).length])]);
|
|
349
|
+
$('pending-count').textContent = waiting.length || '';
|
|
350
|
+
document.title = (waiting.length ? '(' + waiting.length + ') ' : '') + 'Secure';
|
|
351
|
+
const notes = s.notices.map(n => el('div', { className: 'card' }, el('div', { className: 'row' }, el('p', { className: 'grow', textContent: n.text }), el('button', { className: 'ghost', textContent: 'Dismiss', onclick: () => api('/human/dismiss', { id: n.id }).then(() => load()) }))));
|
|
352
|
+
if (sig !== pendingSig) { pendingSig = sig; $('pending').replaceChildren(...notes, ...(checking ? [el('p', { className: 'muted', textContent: 'Your agent is checking ' + (checking === 1 ? '1 question' : checking + ' questions') + ' for change or secret requests…' })] : []), ...(waiting.length ? waiting.map(pendingCard) : [el('p', { className: 'empty', textContent: 'Nothing waiting. New requests appear here.' })])); }
|
|
353
|
+
const busy = document.activeElement && document.activeElement.tagName === 'INPUT' && document.activeElement.value;
|
|
354
|
+
const psig = JSON.stringify(s.peers);
|
|
355
|
+
if ((psig !== peersSig || force) && !busy) { peersSig = psig; $('peers').replaceChildren(...(s.peers.length ? s.peers.map(peerCard) : [el('p', { className: 'empty', textContent: 'No paired computers yet. Ask your agent to "create a Secure invite".' })])); }
|
|
356
|
+
const hp = $('hist-peer'); if (hp.options.length !== s.peers.length + 1) { const v = hp.value; hp.replaceChildren(el('option', { value: '', textContent: 'All computers' }), ...s.peers.map(p => el('option', { value: p.name, textContent: p.name }))); hp.value = v; }
|
|
357
|
+
const bsig = JSON.stringify(s.decisionsList);
|
|
358
|
+
if (bsig !== brainSig) { brainSig = bsig; $('brain').replaceChildren(...brainCards(s.decisionsList)); }
|
|
359
|
+
}
|
|
360
|
+
show(tab);
|
|
361
|
+
load(); setInterval(() => load().catch(() => {}), 2000);
|
|
362
|
+
</script></body></html>`;
|
|
363
|
+
}
|
|
364
|
+
function lock() {
|
|
365
|
+
return '<svg width="55%" height="55%" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 0 1 8 0v3"/></svg>';
|
|
366
|
+
}
|
package/dist/update.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Updates: each computer runs a pinned copy in ~/.secure/app. Newer versions are found on npm but only installed after the person's yes.
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { file } from './config.js';
|
|
7
|
+
const PKG = 'secure-agents';
|
|
8
|
+
const isWin = process.platform === 'win32';
|
|
9
|
+
export const APP_DIR = file('app');
|
|
10
|
+
export const appCli = () => path.join(APP_DIR, 'node_modules', PKG, 'dist', 'cli.js');
|
|
11
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
export function currentVersion() {
|
|
13
|
+
return JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8')).version;
|
|
14
|
+
}
|
|
15
|
+
/** The version of the code running now; read once, because an update replaces the files on disk. */
|
|
16
|
+
export const RUNNING_VERSION = currentVersion();
|
|
17
|
+
export function installedVersion() {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(fs.readFileSync(path.join(APP_DIR, 'node_modules', PKG, 'package.json'), 'utf8')).version;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** How this copy is running: the pinned app, a source folder (git clone), or a one-off download (npx). */
|
|
26
|
+
export function runningAs() {
|
|
27
|
+
if (path.resolve(here).startsWith(path.resolve(APP_DIR) + path.sep))
|
|
28
|
+
return 'app';
|
|
29
|
+
return fs.existsSync(path.join(here, '..', '.git')) ? 'source' : 'download';
|
|
30
|
+
}
|
|
31
|
+
const registry = () => (process.env.SECURE_REGISTRY ?? process.env.npm_config_registry ?? 'https://registry.npmjs.org').replace(/\/$/, '');
|
|
32
|
+
/** The newest release on npm, with its short list of what changed (the "changes" field of its package.json). */
|
|
33
|
+
export async function latestRelease() {
|
|
34
|
+
try {
|
|
35
|
+
const r = await fetch(`${registry()}/${PKG.replace('/', '%2f')}/latest`, { signal: AbortSignal.timeout(5000) });
|
|
36
|
+
if (!r.ok)
|
|
37
|
+
return null;
|
|
38
|
+
const j = (await r.json());
|
|
39
|
+
return j.version ? { version: j.version, notes: Array.isArray(j.changes) ? j.changes.filter((x) => typeof x === 'string').slice(0, 30) : [] } : null;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export async function latestVersion() { return (await latestRelease())?.version ?? null; }
|
|
46
|
+
export function newer(a, b) {
|
|
47
|
+
const pa = a.split(/[.-]/).map(Number), pb = b.split(/[.-]/).map(Number);
|
|
48
|
+
for (let i = 0; i < 3; i++)
|
|
49
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0))
|
|
50
|
+
return (pa[i] ?? 0) > (pb[i] ?? 0);
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
export const changesUrl = (_from, to) => `https://www.npmjs.com/package/${PKG}/v/${to}?activeTab=code`;
|
|
54
|
+
export async function checkUpdate() {
|
|
55
|
+
const current = RUNNING_VERSION;
|
|
56
|
+
const rel = await latestRelease();
|
|
57
|
+
return rel && newer(rel.version, current) ? { current, latest: rel.version, changes: changesUrl(current, rel.version), notes: rel.notes } : null;
|
|
58
|
+
}
|
|
59
|
+
/** Installs exactly this version into the app folder. Install scripts are not run. */
|
|
60
|
+
export function installVersion(version) {
|
|
61
|
+
fs.mkdirSync(APP_DIR, { recursive: true, mode: 0o700 });
|
|
62
|
+
if (!fs.existsSync(path.join(APP_DIR, 'package.json')))
|
|
63
|
+
fs.writeFileSync(path.join(APP_DIR, 'package.json'), '{"private":true}\n');
|
|
64
|
+
const args = ['install', '--prefix', APP_DIR, '--no-audit', '--no-fund', '--ignore-scripts', '--registry', registry(), `${PKG}@${version}`];
|
|
65
|
+
const r = spawnSync(isWin ? 'npm.cmd' : 'npm', args, { encoding: 'utf8', shell: isWin, timeout: 300_000 });
|
|
66
|
+
if (r.status !== 0 || installedVersion() !== version)
|
|
67
|
+
throw new Error(`Could not install Secure ${version}: ${`${r.stderr ?? ''}${r.stdout ?? ''}`.trim().split('\n').slice(-3).join(' ')}`);
|
|
68
|
+
}
|
package/dist/vault.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The passphrase lock: only the person knows it. It encrypts Secure's data on disk (so an agent can't read or forge it) and is the only way to approve anything.
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import { file } from './config.js';
|
|
5
|
+
const SCRYPT = { N: 1 << 15, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };
|
|
6
|
+
let key = null;
|
|
7
|
+
export const MIN_PASSPHRASE = 10;
|
|
8
|
+
function derive(passphrase, salt) { return crypto.scryptSync(passphrase.normalize('NFKC'), salt, 32, SCRYPT); }
|
|
9
|
+
const checkOf = (k) => crypto.createHmac('sha256', k).update('securee-vault-check').digest('hex');
|
|
10
|
+
export function vaultExists() { return fs.existsSync(file('vault.json')); }
|
|
11
|
+
export function createVault(passphrase) {
|
|
12
|
+
if (passphrase.length < MIN_PASSPHRASE)
|
|
13
|
+
throw new Error(`Use a passphrase of at least ${MIN_PASSPHRASE} characters.`);
|
|
14
|
+
if (vaultExists())
|
|
15
|
+
throw new Error('This computer already has a Secure passphrase.');
|
|
16
|
+
const salt = crypto.randomBytes(16);
|
|
17
|
+
const k = derive(passphrase, salt);
|
|
18
|
+
const v = { v: 1, salt: salt.toString('hex'), check: checkOf(k) };
|
|
19
|
+
fs.writeFileSync(file('vault.json'), JSON.stringify(v), { mode: 0o600 });
|
|
20
|
+
key = k;
|
|
21
|
+
}
|
|
22
|
+
/** Checks a passphrase without changing what is unlocked. */
|
|
23
|
+
export function passphraseMatches(passphrase) {
|
|
24
|
+
const v = JSON.parse(fs.readFileSync(file('vault.json'), 'utf8'));
|
|
25
|
+
const got = Buffer.from(checkOf(derive(passphrase, Buffer.from(v.salt, 'hex'))), 'hex');
|
|
26
|
+
return crypto.timingSafeEqual(got, Buffer.from(v.check, 'hex'));
|
|
27
|
+
}
|
|
28
|
+
export function unlock(passphrase) {
|
|
29
|
+
if (!vaultExists())
|
|
30
|
+
throw new Error('No passphrase set on this computer. Run: secure init --name <agent-name>');
|
|
31
|
+
if (!passphraseMatches(passphrase))
|
|
32
|
+
throw new Error('Wrong passphrase.');
|
|
33
|
+
const v = JSON.parse(fs.readFileSync(file('vault.json'), 'utf8'));
|
|
34
|
+
key = derive(passphrase, Buffer.from(v.salt, 'hex'));
|
|
35
|
+
}
|
|
36
|
+
/** For handing the unlocked key to the next version after an update, over a private IPC channel (never disk). */
|
|
37
|
+
export function exportKey() { return requireKey().toString('hex'); }
|
|
38
|
+
export function useKey(hex) {
|
|
39
|
+
const v = JSON.parse(fs.readFileSync(file('vault.json'), 'utf8'));
|
|
40
|
+
const k = Buffer.from(hex, 'hex');
|
|
41
|
+
if (k.length !== 32 || !crypto.timingSafeEqual(Buffer.from(checkOf(k), 'hex'), Buffer.from(v.check, 'hex')))
|
|
42
|
+
throw new Error('The handed-over key does not match this computer.');
|
|
43
|
+
key = k;
|
|
44
|
+
}
|
|
45
|
+
function requireKey() {
|
|
46
|
+
if (!key)
|
|
47
|
+
throw new Error('Secure is locked.');
|
|
48
|
+
return key;
|
|
49
|
+
}
|
|
50
|
+
/** AES-256-GCM: anyone without the passphrase can neither read nor change the file without it being refused. */
|
|
51
|
+
export function seal(name, value) {
|
|
52
|
+
const iv = crypto.randomBytes(12);
|
|
53
|
+
const c = crypto.createCipheriv('aes-256-gcm', requireKey(), iv);
|
|
54
|
+
c.setAAD(Buffer.from(name));
|
|
55
|
+
const data = Buffer.concat([c.update(JSON.stringify(value), 'utf8'), c.final()]);
|
|
56
|
+
return { sealed: 1, iv: iv.toString('hex'), tag: c.getAuthTag().toString('hex'), data: data.toString('base64') };
|
|
57
|
+
}
|
|
58
|
+
export function open(name, s) {
|
|
59
|
+
const d = crypto.createDecipheriv('aes-256-gcm', requireKey(), Buffer.from(s.iv, 'hex'));
|
|
60
|
+
d.setAAD(Buffer.from(name));
|
|
61
|
+
d.setAuthTag(Buffer.from(s.tag, 'hex'));
|
|
62
|
+
try {
|
|
63
|
+
return JSON.parse(Buffer.concat([d.update(Buffer.from(s.data, 'base64')), d.final()]).toString('utf8'));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
throw new Error(`${name} was changed outside Secure, so it is not trusted. Restore it from a backup or delete it.`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function readSealed(name, fallback) {
|
|
70
|
+
let raw;
|
|
71
|
+
try {
|
|
72
|
+
raw = fs.readFileSync(file(name), 'utf8');
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return fallback;
|
|
76
|
+
}
|
|
77
|
+
const parsed = JSON.parse(raw);
|
|
78
|
+
if (parsed?.sealed !== 1)
|
|
79
|
+
throw new Error(`${name} is not locked by this computer's passphrase, so it is not trusted.`);
|
|
80
|
+
return open(name, parsed);
|
|
81
|
+
}
|
|
82
|
+
export function writeSealed(name, value) {
|
|
83
|
+
const tmp = file(`${name}.tmp`);
|
|
84
|
+
fs.writeFileSync(tmp, JSON.stringify(seal(name, value)), { mode: 0o600 });
|
|
85
|
+
fs.renameSync(tmp, file(name));
|
|
86
|
+
}
|
|
87
|
+
export function mac(data) { return crypto.createHmac('sha256', requireKey()).update(data).digest('hex'); }
|