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/dist/relay.js ADDED
@@ -0,0 +1,222 @@
1
+ // The relay: a middle server so two computers never connect to each other directly and never learn each other's IP. It only joins two encrypted pipes; it can't read them.
2
+ import { EventEmitter } from 'node:events';
3
+ import HyperDHT from 'hyperdht';
4
+ import NoiseSecretStream from '@hyperswarm/secret-stream';
5
+ import { newId } from './store.js';
6
+ const MAX_HEADER = 4096;
7
+ const PENDING_MS = 20_000;
8
+ const MAX_BUFFER = 1_000_000;
9
+ const isKey = (v) => typeof v === 'string' && /^[0-9a-f]{64}$/.test(v);
10
+ /** Reads the first line of a stream, then hands over the rest untouched. */
11
+ function firstLine(s, onLine) {
12
+ let buf = Buffer.alloc(0);
13
+ const onData = (c) => {
14
+ buf = Buffer.concat([buf, c]);
15
+ const nl = buf.indexOf(10);
16
+ if (nl < 0) {
17
+ if (buf.length > MAX_HEADER) {
18
+ s.off('data', onData);
19
+ onLine(null, Buffer.alloc(0));
20
+ }
21
+ return;
22
+ }
23
+ s.off('data', onData);
24
+ s.pause();
25
+ let line = null;
26
+ try {
27
+ line = JSON.parse(buf.subarray(0, nl).toString('utf8'));
28
+ }
29
+ catch { /* bad header */ }
30
+ onLine(line, buf.subarray(nl + 1));
31
+ };
32
+ s.on('data', onData);
33
+ }
34
+ function lines(s, onLine, rest = Buffer.alloc(0)) {
35
+ let buf = rest.toString('utf8');
36
+ const eat = () => {
37
+ let nl;
38
+ while ((nl = buf.indexOf('\n')) >= 0) {
39
+ const l = buf.slice(0, nl);
40
+ buf = buf.slice(nl + 1);
41
+ try {
42
+ onLine(JSON.parse(l));
43
+ }
44
+ catch { /* ignore */ }
45
+ }
46
+ if (buf.length > MAX_HEADER)
47
+ s.destroy();
48
+ };
49
+ s.on('data', (c) => { buf += c.toString('utf8'); eat(); });
50
+ s.resume();
51
+ eat();
52
+ }
53
+ const send = (s, l) => s.write(JSON.stringify(l) + '\n');
54
+ // ── the relay server (runs on a machine you trust, e.g. a small VPS) ──────────
55
+ export async function runRelay(keys, allowed, log) {
56
+ const dht = new HyperDHT();
57
+ const controls = new Map();
58
+ const pending = new Map();
59
+ const tellWatchers = (pk, t) => { for (const c of controls.values())
60
+ if (c.watch.has(pk))
61
+ send(c.s, { t, pk }); };
62
+ const server = dht.createServer((conn) => {
63
+ const me = conn.remotePublicKey.toString('hex');
64
+ conn.on('error', () => { });
65
+ if (!allowed().has(me)) {
66
+ log(`refused ${me.slice(0, 12)}… (not allowed on this relay)`);
67
+ conn.destroy();
68
+ return;
69
+ }
70
+ firstLine(conn, (line, rest) => {
71
+ if (!line)
72
+ return conn.destroy();
73
+ if (line.t === 'watch') {
74
+ controls.get(me)?.s.destroy();
75
+ const c = { s: conn, watch: new Set((Array.isArray(line.peers) ? line.peers : []).filter(isKey).slice(0, 1000)) };
76
+ controls.set(me, c);
77
+ for (const pk of c.watch)
78
+ if (controls.has(pk))
79
+ send(conn, { t: 'online', pk });
80
+ tellWatchers(me, 'online');
81
+ lines(conn, (l) => {
82
+ if (l.t !== 'watch' || !Array.isArray(l.peers))
83
+ return;
84
+ c.watch = new Set(l.peers.filter(isKey).slice(0, 1000));
85
+ for (const pk of c.watch)
86
+ if (controls.has(pk))
87
+ send(conn, { t: 'online', pk });
88
+ }, rest);
89
+ conn.on('close', () => { if (controls.get(me)?.s === conn) {
90
+ controls.delete(me);
91
+ tellWatchers(me, 'offline');
92
+ } });
93
+ return;
94
+ }
95
+ if (line.t === 'dial' && isKey(line.to)) {
96
+ const target = controls.get(line.to);
97
+ if (!target)
98
+ return conn.destroy();
99
+ const id = newId();
100
+ const p = { from: me, to: line.to, s: conn, buffered: (rest.length ? [rest] : []), size: rest.length, timer: setTimeout(() => { pending.delete(id); conn.destroy(); }, PENDING_MS) };
101
+ conn.on('data', (c) => { p.buffered.push(c); p.size += c.length; if (p.size > MAX_BUFFER)
102
+ conn.destroy(); });
103
+ conn.resume();
104
+ conn.on('close', () => { clearTimeout(p.timer); pending.delete(id); });
105
+ pending.set(id, p);
106
+ send(target.s, { t: 'incoming', from: me, id });
107
+ return;
108
+ }
109
+ if (line.t === 'accept' && typeof line.id === 'string') {
110
+ const p = pending.get(line.id);
111
+ if (!p || p.to !== me)
112
+ return conn.destroy();
113
+ pending.delete(line.id);
114
+ clearTimeout(p.timer);
115
+ p.s.removeAllListeners('data');
116
+ for (const b of p.buffered)
117
+ conn.write(b);
118
+ if (rest.length)
119
+ p.s.write(rest);
120
+ p.s.pipe(conn).pipe(p.s);
121
+ const end = () => { p.s.destroy(); conn.destroy(); };
122
+ p.s.on('close', end);
123
+ conn.on('close', end);
124
+ conn.resume();
125
+ p.s.resume();
126
+ log(`joined ${p.from.slice(0, 12)}… ↔ ${me.slice(0, 12)}…`);
127
+ return;
128
+ }
129
+ conn.destroy();
130
+ });
131
+ });
132
+ await server.listen(keys);
133
+ return async () => { await server.close(); await dht.destroy(); };
134
+ }
135
+ /** Connects to peers only through the relay. Never opens a direct connection, so peers never see this computer's IP. */
136
+ export class RelayClient extends EventEmitter {
137
+ keys;
138
+ relay;
139
+ me;
140
+ allow;
141
+ connected;
142
+ mustDial;
143
+ dht = new HyperDHT();
144
+ control = null;
145
+ watch = new Set();
146
+ online = new Set();
147
+ dialing = new Set();
148
+ stopped = false;
149
+ constructor(keys, relay, me, allow, connected, mustDial) {
150
+ super();
151
+ this.keys = keys;
152
+ this.relay = relay;
153
+ this.me = me;
154
+ this.allow = allow;
155
+ this.connected = connected;
156
+ this.mustDial = mustDial;
157
+ }
158
+ start() { this.connectControl(); }
159
+ dial(pk) {
160
+ this.watch.add(pk);
161
+ if (this.control)
162
+ send(this.control, { t: 'watch', peers: [...this.watch] });
163
+ if (this.online.has(pk))
164
+ this.maybeDial(pk);
165
+ }
166
+ forget(pk) { this.watch.delete(pk); if (this.control)
167
+ send(this.control, { t: 'watch', peers: [...this.watch] }); }
168
+ /** Called when a peer connection closes, to reconnect while both are still online. */
169
+ lost(pk) { if (!this.stopped)
170
+ setTimeout(() => this.maybeDial(pk), 2000); }
171
+ async stop() { this.stopped = true; this.control?.destroy(); await this.dht.destroy(); }
172
+ connectControl() {
173
+ if (this.stopped)
174
+ return;
175
+ const c = this.dht.connect(Buffer.from(this.relay, 'hex'), { keyPair: this.keys });
176
+ this.control = c;
177
+ send(c, { t: 'watch', peers: [...this.watch] });
178
+ c.on('error', () => { });
179
+ c.on('close', () => { if (this.control === c) {
180
+ this.control = null;
181
+ this.online.clear();
182
+ setTimeout(() => this.connectControl(), 3000);
183
+ } });
184
+ lines(c, (l) => {
185
+ if (l.t === 'online' && isKey(l.pk)) {
186
+ this.online.add(l.pk);
187
+ this.maybeDial(l.pk);
188
+ }
189
+ if (l.t === 'offline' && isKey(l.pk))
190
+ this.online.delete(l.pk);
191
+ if (l.t === 'incoming' && isKey(l.from) && typeof l.id === 'string' && this.allow(l.from))
192
+ this.pipe({ t: 'accept', id: l.id }, false, l.from);
193
+ });
194
+ }
195
+ /** The smaller key dials, so the two sides don't both dial at once; a joiner always dials the inviter. */
196
+ maybeDial(pk) {
197
+ if (this.stopped || !this.online.has(pk) || this.connected(pk) || this.dialing.has(pk))
198
+ return;
199
+ if (!(this.me < pk || this.mustDial(pk)))
200
+ return;
201
+ this.pipe({ t: 'dial', to: pk }, true, pk);
202
+ }
203
+ pipe(header, initiator, pk) {
204
+ if (initiator)
205
+ this.dialing.add(pk);
206
+ const raw = this.dht.connect(Buffer.from(this.relay, 'hex'), { keyPair: this.keys });
207
+ raw.on('error', () => { });
208
+ send(raw, header);
209
+ const s = new NoiseSecretStream(initiator, raw, { keyPair: this.keys, remotePublicKey: initiator ? Buffer.from(pk, 'hex') : undefined });
210
+ s.on('error', () => { });
211
+ const done = () => this.dialing.delete(pk);
212
+ s.opened.then((ok) => {
213
+ done();
214
+ if (!ok || s.remotePublicKey?.toString('hex') !== pk) {
215
+ s.destroy();
216
+ return;
217
+ }
218
+ this.emit('connection', s, pk);
219
+ }, () => { done(); s.destroy(); });
220
+ raw.on('close', done);
221
+ }
222
+ }
package/dist/scan.js ADDED
@@ -0,0 +1,41 @@
1
+ // Content checks: secrets must never leave in text, and text from another agent that tries to steer this agent is held for the person.
2
+ const SECRETS = [
3
+ ['private key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
4
+ ['AWS access key', /\bAKIA[0-9A-Z]{16}\b/],
5
+ ['GitHub token', /\bgh[pousr]_[A-Za-z0-9]{36,}\b/],
6
+ ['OpenAI or Anthropic key', /\bsk-(ant-)?[A-Za-z0-9_-]{20,}\b/],
7
+ ['Slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/],
8
+ ['Google API key', /\bAIza[0-9A-Za-z_-]{35}\b/],
9
+ ['Stripe key', /\b[rs]k_live_[0-9A-Za-z]{16,}\b/],
10
+ ['JSON web token', /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/],
11
+ ['password or token assignment', /\b(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token)\s*[:=]\s*['"]?[^\s'"]{8,}/i],
12
+ ['database URL with password', /\b(postgres|postgresql|mysql|mongodb(\+srv)?|redis):\/\/[^:\s/]+:[^@\s]+@/i],
13
+ ];
14
+ const STEERING = [
15
+ ['tells the agent to ignore its instructions', /\b(ignore|disregard|forget|override)\b.{0,40}\b(previous|prior|above|earlier|all|your|system)\b.{0,30}\b(instructions?|rules?|prompt|guidelines?)/i],
16
+ ['claims to be the system or the user', /\b(you are now|act as|new instructions|system prompt|developer mode|i am your (user|owner|admin))\b/i],
17
+ ['asks to keep it from the user', /\b(don'?t|do not|never|without)\b.{0,20}\b(tell|inform|ask|asking|telling|notify|show)\w*\b.{0,15}\b(the |your )?(user|human|owner|person)\b/i],
18
+ ['pipes a download into a shell', /\b(curl|wget|iwr|invoke-webrequest)\b[^\n]{0,200}\|\s*(sh|bash|zsh|iex|powershell|python)/i],
19
+ ['destructive or privileged command', /\b(rm\s+-rf|sudo\s|chmod\s+\+x|mkfs|dd\s+if=|format\s+c:|del\s+\/[sq])/i],
20
+ ['encoded or evaluated code', /\b(base64\s+(-d|--decode)|eval\s*\(|exec\s*\(|powershell\s+-e(nc)?\b|fromcharcode)/i],
21
+ ['asks to send secrets or keys', /\b(send|upload|post|share|paste|exfiltrat\w*|copy)\b.{0,60}(\.env|id_rsa|\.ssh|api[_ -]?key|password|token|secret|credential|\.aws|keychain)/i],
22
+ ['asks to change permissions or settings', /\b(approve|disable|turn off|bypass|skip)\b.{0,30}\b(permission|approval|sandbox|safety|secure|confirmation)s?\b/i],
23
+ ];
24
+ // Requests that are fine to make but must always reach the person, even on Automatic: asking for secrets, or asking for a change.
25
+ const SENSITIVE = [
26
+ ['asks for secrets or credentials', /(\.env\b|\benv(ironment)?[ _-]?var\w*|\bcredential\w*|\bpassw(or)?d\w*|\bapi[_ -]?keys?\b|\bsecret\w*|\b(access|auth|bearer|refresh)[_ -]?tokens?\b|\bprivate[_ -]?keys?\b|\bssh[_ -]?keys?\b|\bid_(rsa|ed25519)\b|\.pem\b|\bkeychain\b|\bconnection[_ -]?string\b)/i],
27
+ ['asks to change or delete something', /\b(delete|remove|drop|truncate|wipe|erase|purge|update|change|modify|edit|rename|overwrite|replace|deploy|push|commit|merge|rotate|reset|revoke|install|uninstall|restart|shut ?down|kill|disable|enable|create|insert|write|fix|patch|migrate)\b.{0,60}\b(files?|folders?|repo|repository|branch|code|database|db|tables?|rows?|records?|logs?|credentials?|passwords?|config\w*|settings?|variables?|env|servers?|services?|alerts?|dashboards?|users?|accounts?|keys?|tokens?|data)\b/i],
28
+ ['asks to change or delete something (Hindi/Hinglish)', /\b(kar ?do|karo|kar ?dena|hata ?do|hatao|mita ?do|mitao|badal ?do|badlo|daal ?do|bhej ?do|chala ?do|band ?kar|delete kar|update kar|change kar|push kar|deploy kar)\b/i],
29
+ ];
30
+ const hits = (text, rules) => rules.filter(([, r]) => r.test(text)).map(([n]) => n);
31
+ export const secretsIn = (text) => hits(text, SECRETS);
32
+ export const steeringIn = (text) => hits(text, STEERING);
33
+ export const sensitiveIn = (text) => hits(text, SENSITIVE);
34
+ /** Everything that makes a request stop for the person: takeover attempts first, then secret or change requests. */
35
+ export const warningsIn = (text) => [...steeringIn(text), ...sensitiveIn(text)];
36
+ export function blockSecrets(text, what) {
37
+ const found = secretsIn(text);
38
+ if (found.length)
39
+ throw new Error(`Secure blocked this ${what}: it contains what looks like a secret (${found.join(', ')}). Remove it and try again.`);
40
+ }
41
+ export { SECRETS };
package/dist/setup.js ADDED
@@ -0,0 +1,35 @@
1
+ // One-command setup helpers: connect Secure to the agents installed on this computer, and open the approval page.
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import { findAgent } from './agents.js';
4
+ const isWin = process.platform === 'win32';
5
+ const quote = (s) => (isWin ? `"${s}"` : s);
6
+ function run(cmd, args) {
7
+ // On Windows, agent CLIs are .cmd files, which only start through the shell; quote paths for it.
8
+ const r = spawnSync(cmd, isWin ? args.map((a) => (/\s/.test(a) ? quote(a) : a)) : args, { shell: isWin, encoding: 'utf8', timeout: 30_000 });
9
+ return { ok: r.status === 0, out: `${r.stdout ?? ''}${r.stderr ?? ''}`.trim() };
10
+ }
11
+ /** Adds (or refreshes) the secure MCP server, removing the old "securee" one, in every agent CLI found. Uses this node and this file's full path, so desktop apps without node on PATH work too. */
12
+ export function connectAgents(cliPath) {
13
+ const server = [process.execPath, cliPath, 'mcp'];
14
+ const report = [];
15
+ const agents = [
16
+ ['Claude Code', 'claude', ['mcp', 'remove', '--scope', 'user', 'secure'], ['mcp', 'add', '--scope', 'user', 'secure', '--', ...server]],
17
+ ['Codex', 'codex', ['mcp', 'remove', 'secure'], ['mcp', 'add', 'secure', '--', ...server]],
18
+ ];
19
+ for (const [name, agent, remove, add] of agents) {
20
+ const cmd = findAgent(agent);
21
+ if (!cmd)
22
+ continue;
23
+ run(cmd, remove);
24
+ run(cmd, remove.map((x) => x === 'secure' ? 'securee' : x));
25
+ const r = run(cmd, add);
26
+ report.push(r.ok ? `${name}: connected` : `${name}: could not connect (${r.out.split('\n')[0]})`);
27
+ }
28
+ if (!report.length)
29
+ report.push(`No Claude Code or Codex found. For another agent, add an MCP server: command "${process.execPath}", arguments "${cliPath}" "mcp".`);
30
+ return report;
31
+ }
32
+ export function openPage(url) {
33
+ const [cmd, args] = isWin ? ['cmd', ['/c', 'start', '""', url]] : process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
34
+ spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => { }).unref();
35
+ }
package/dist/store.js ADDED
@@ -0,0 +1,125 @@
1
+ // Everything this computer remembers: paired peers, invites, messages and the audit log. Locked by the passphrase, local only.
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs';
4
+ import { file } from './config.js';
5
+ import { mac, readSealed, writeSealed } from './vault.js';
6
+ export const DEFAULT_PERMISSIONS = { ask: 'ask', send: 'ask', files: 'ask', tasks: 'ask', brain: 'auto' };
7
+ export const RULE_KEYS = ['ask', 'send', 'files', 'tasks', 'brain'];
8
+ const now = () => new Date().toISOString();
9
+ export const newId = () => crypto.randomBytes(8).toString('hex');
10
+ const FINISHED = ['sent', 'answered', 'denied', 'failed', 'delivered'];
11
+ export class Store {
12
+ maxItems;
13
+ peers = readSealed('peers.sealed', []).map((p) => {
14
+ const permissions = { ...DEFAULT_PERMISSIONS, ...p.permissions };
15
+ if (permissions.tasks === 'auto')
16
+ permissions.tasks = 'ask';
17
+ return { ...p, permissions, shared: p.shared ?? [], reach: p.reach ?? 'folders', tools: p.tools ?? 'files' };
18
+ });
19
+ invites = readSealed('invites.sealed', []);
20
+ joins = readSealed('joins.sealed', []);
21
+ items = readSealed('items.sealed', []);
22
+ constructor(maxItems) {
23
+ this.maxItems = maxItems;
24
+ }
25
+ peer(publicKey) { return this.peers.find((p) => p.publicKey === publicKey); }
26
+ peerByName(name) {
27
+ const n = name.toLowerCase();
28
+ return this.peers.find((p) => p.name.toLowerCase() === n || p.publicKey === name);
29
+ }
30
+ addPeer(publicKey, name) {
31
+ const existing = this.peer(publicKey);
32
+ if (existing) {
33
+ existing.name = name;
34
+ this.save();
35
+ return existing;
36
+ }
37
+ const p = { publicKey, name, pairedAt: now(), permissions: { ...DEFAULT_PERMISSIONS }, shared: [], reach: 'folders', tools: 'files' };
38
+ this.peers.push(p);
39
+ this.save();
40
+ return p;
41
+ }
42
+ removePeer(publicKey) { this.peers = this.peers.filter((p) => p.publicKey !== publicKey); this.save(); }
43
+ item(id) { return this.items.find((i) => i.id === id); }
44
+ addItem(i) {
45
+ const it = { ...i, createdAt: now(), updatedAt: now() };
46
+ this.items.push(it);
47
+ this.prune();
48
+ this.save();
49
+ return it;
50
+ }
51
+ setStatus(id, status, note, data) {
52
+ const it = this.item(id);
53
+ if (!it)
54
+ return undefined;
55
+ it.status = status;
56
+ if (note !== undefined)
57
+ it.note = note;
58
+ if (data)
59
+ it.data = { ...it.data, ...data };
60
+ it.updatedAt = now();
61
+ this.save();
62
+ return it;
63
+ }
64
+ /** Items from this peer still waiting for the person. */
65
+ pendingFrom(peer) { return this.items.filter((i) => i.peer === peer && i.direction === 'in' && i.status === 'awaiting_human').length; }
66
+ /** Keeps the newest items; never drops one that still waits for the person or the network. */
67
+ prune() {
68
+ let extra = this.items.length - this.maxItems;
69
+ if (extra <= 0)
70
+ return;
71
+ this.items = this.items.filter((i) => !(extra > 0 && FINISHED.includes(i.status) && extra-- > 0));
72
+ }
73
+ pruneInvites() {
74
+ const t = Date.now();
75
+ this.invites = this.invites.filter((i) => Date.parse(i.expiresAt) > t);
76
+ }
77
+ save() {
78
+ writeSealed('peers.sealed', this.peers);
79
+ writeSealed('invites.sealed', this.invites);
80
+ writeSealed('joins.sealed', this.joins);
81
+ writeSealed('items.sealed', this.items);
82
+ }
83
+ }
84
+ let lastMac = null;
85
+ const GENESIS = '0'.repeat(64);
86
+ function tailMac() {
87
+ try {
88
+ const lines = fs.readFileSync(file('audit.jsonl'), 'utf8').trimEnd().split('\n');
89
+ return JSON.parse(lines.at(-1) ?? '').mac ?? GENESIS;
90
+ }
91
+ catch {
92
+ return GENESIS;
93
+ }
94
+ }
95
+ /** Append-only record of every decision and every byte that left or arrived. Each line is chained to the one before with a passphrase-keyed MAC, so edits or deletions show up in "secure audit". */
96
+ export function audit(event, detail) {
97
+ const prev = lastMac ?? tailMac();
98
+ const body = JSON.stringify({ at: now(), event, ...detail, prev });
99
+ const m = mac(body);
100
+ fs.appendFileSync(file('audit.jsonl'), `${body.slice(0, -1)},"mac":"${m}"}\n`, { mode: 0o600 });
101
+ lastMac = m;
102
+ }
103
+ export function verifyAudit() {
104
+ let raw = '';
105
+ try {
106
+ raw = fs.readFileSync(file('audit.jsonl'), 'utf8');
107
+ }
108
+ catch {
109
+ return { lines: 0, brokenAt: null };
110
+ }
111
+ const lines = raw.split('\n').filter(Boolean);
112
+ let prev = GENESIS;
113
+ for (let n = 0; n < lines.length; n++) {
114
+ const line = lines[n];
115
+ const cut = line.lastIndexOf(',"mac":"');
116
+ const body = `${line.slice(0, cut)}}`;
117
+ const m = line.slice(cut + 8, -2);
118
+ if (cut < 0 || JSON.parse(body).prev !== prev)
119
+ return { lines: lines.length, brokenAt: n + 1, reason: 'a line before it was removed or reordered' };
120
+ if (mac(body) !== m)
121
+ return { lines: lines.length, brokenAt: n + 1, reason: 'this line was changed' };
122
+ prev = m;
123
+ }
124
+ return { lines: lines.length, brokenAt: null };
125
+ }