Skip to content

Commit 8ea475e

Browse files
committed
feat(safe-mode): Phase 19 - WAB Safe Mode SDK + Provider Kit endpoint
Two complementary additions implementing the strategic adoption plan: make WAB the safer, cheaper default both for agents and for hosts. (1) sdk/safe-mode.js — WABSafeMode class Trust-aware gate any agent can wrap actions in. Combines the existing /api/discovery/score, /compliance, and (optional live) /trust endpoints into a single evaluate(domain) call returning trust level 0-3: L3 - DNS + valid Ed25519 sig + score >= 60 -> full execute L2 - DNS + wab.json (no sig or low score) -> limited execute L1 - resolves but no _wab / restrict verdict -> read-only L0 - compliance deny / suspicious -> blocked Provides guardExecute() and guardRead() wrappers that throw WABSafeModeError when the policy denies, plus pickBest(domains) for selecting the most trusted target from a candidate list. 60s in-memory cache, 8s timeout, configurable policy (strict|standard|permissive). Exported from sdk/index.js as WABSafeMode + WABSafeModeError. (2) examples/safe-mode-agent.js — Demo3 CLI demo showing the WAB-vs-non-WAB difference side-by-side. Run: node examples/safe-mode-agent.js wab-site.com untrusted-site.com Renders trust level + verdict + reasons in colour, then simulates the agent acting under Safe Mode (full / read-only / refused). (3) server/routes/providers.js — GET /api/providers/kit/:provider Public, unauthenticated kit endpoint that returns everything a hosting provider needs to ship a one-click 'Enable AI Access (WAB)' button: DNS template, signed wab.json starter (messaging|booking|generic), self-contained Ed25519 generator script, validation curl, and provider-specific UI hints (Cloudflare/Hostinger/Route53/Azure/GCP/ cPanel/Plesk/GoDaddy/Namecheap/generic). Index at GET /api/providers/kit lists all supported providers. Bound to the existing authenticated /quick-enable endpoint so providers can preview before integrating.
1 parent 3fb9ba7 commit 8ea475e

4 files changed

Lines changed: 485 additions & 0 deletions

File tree

examples/safe-mode-agent.js

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* Demo3 — Safe Mode Agent
3+
*
4+
* Shows the difference between a domain that has WAB enabled (full trust,
5+
* full execute) and one that doesn't (read-only / blocked).
6+
*
7+
* node examples/safe-mode-agent.js wab-site.com untrusted-site.com
8+
*
9+
* Or pass --policy=strict|standard|permissive to change the gate.
10+
*/
11+
12+
'use strict';
13+
14+
const { WABSafeMode } = require('../sdk');
15+
16+
const args = process.argv.slice(2);
17+
const flags = {};
18+
const domains = [];
19+
for (const a of args) {
20+
if (a.startsWith('--')) {
21+
const [k, v] = a.replace(/^--/, '').split('=');
22+
flags[k] = v ?? true;
23+
} else domains.push(a);
24+
}
25+
if (domains.length === 0) {
26+
console.error('Usage: node examples/safe-mode-agent.js <domain1> [<domain2> ...] [--policy=standard]');
27+
console.error(' [--api=https://your-wab.example.com]');
28+
process.exit(2);
29+
}
30+
31+
const safe = new WABSafeMode({
32+
apiBase: flags.api || process.env.WAB_API_BASE || 'https://webagentbridge.com',
33+
policy: flags.policy || 'standard',
34+
});
35+
36+
const COLOR = {
37+
reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m',
38+
green: '\x1b[32m', yellow: '\x1b[33m', red: '\x1b[31m', cyan: '\x1b[36m',
39+
};
40+
function color(c, s) { return process.stdout.isTTY ? `${COLOR[c]}${s}${COLOR.reset}` : s; }
41+
function levelColor(l) { return l >= 3 ? 'green' : l === 2 ? 'cyan' : l === 1 ? 'yellow' : 'red'; }
42+
43+
async function checkOne(d) {
44+
const t0 = Date.now();
45+
const v = await safe.evaluate(d, { live: !!flags.live });
46+
const elapsed = Date.now() - t0;
47+
48+
console.log('');
49+
console.log(color('bold', `── ${v.domain} ──────────────────────────────`));
50+
console.log(`Level : ${color(levelColor(v.level), `L${v.level}`)} (${v.score_label} ${v.score})`);
51+
console.log(`Verdict : ${color(v.verdict === 'allow' ? 'green' : v.verdict === 'restrict' ? 'yellow' : 'red', v.verdict.toUpperCase())}`);
52+
console.log(`Execute : ${v.allow_execute ? color('green', '✓ allowed') : color('red', '✗ blocked')}`);
53+
console.log(`Read : ${v.allow_read ? color('green', '✓ allowed') : color('red', '✗ blocked')}`);
54+
console.log(`Reason : ${v.reason}`);
55+
if (v.reasons && v.reasons.length) {
56+
for (const r of v.reasons) {
57+
const sev = r.severity === 'deny' ? 'red' : r.severity === 'restrict' ? 'yellow' : 'dim';
58+
console.log(color('dim', ' · ') + color(sev, `[${r.severity}] ${r.code}`) + ' ' + (r.message || ''));
59+
}
60+
}
61+
console.log(color('dim', ` (policy=${v.policy}, ${elapsed}ms)`));
62+
63+
// Simulate the agent acting under Safe Mode
64+
try {
65+
if (v.allow_execute) {
66+
await safe.guardExecute(v.domain, async () => {
67+
console.log(color('green', ` → Agent: executing full action on ${v.domain}`));
68+
});
69+
} else if (v.allow_read) {
70+
await safe.guardRead(v.domain, async () => {
71+
console.log(color('yellow', ` → Agent: read-only mode on ${v.domain}`));
72+
});
73+
} else {
74+
console.log(color('red', ` → Agent: refusing to interact with ${v.domain}`));
75+
}
76+
} catch (err) {
77+
console.log(color('red', ` → ${err.message}`));
78+
}
79+
}
80+
81+
(async () => {
82+
console.log(color('bold', `WAB Safe Mode demo — policy=${safe.policy} api=${safe.apiBase}`));
83+
for (const d of domains) {
84+
try { await checkOne(d); } catch (e) { console.error(`Error checking ${d}: ${e.message}`); }
85+
}
86+
console.log('');
87+
88+
// Pick the most trusted target if multiple were given
89+
if (domains.length > 1) {
90+
const best = await safe.pickBest(domains);
91+
if (best) {
92+
console.log(color('bold', `Recommended target: `) + color(levelColor(best.level), best.domain) +
93+
color('dim', ` (L${best.level}, score ${best.score})`));
94+
}
95+
}
96+
})();

sdk/index.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,8 @@ try { WABToolkit = require('../packages/langchain').WABToolkit; } catch {
624624

625625
// SPEC §8.10–§8.13 client helper
626626
const { SafetyShieldClient } = require('./safety-shield');
627+
// Phase 19 — Safe Mode trust gate
628+
const { WABSafeMode, WABSafeModeError, POLICIES: WAB_SAFE_POLICIES } = require('./safe-mode');
627629

628630
module.exports = {
629631
WABAgent,
@@ -633,4 +635,7 @@ module.exports = {
633635
WABAgentOS,
634636
WABToolkit,
635637
SafetyShieldClient,
638+
WABSafeMode,
639+
WABSafeModeError,
640+
WAB_SAFE_POLICIES,
636641
};

sdk/safe-mode.js

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
/**
2+
* WAB Safe Mode — Agent-side trust gate.
3+
*
4+
* Splits the web into Trusted (WAB + valid signature) vs Untrusted, and
5+
* gives the agent a single function to ask before any action:
6+
* await safeMode.evaluate(domain) → { level, verdict, allow_execute,
7+
* allow_read, reason }
8+
*
9+
* Trust levels:
10+
* 3 — DNS + Ed25519 signature valid + telemetry score ≥ 60 (full execute)
11+
* 2 — DNS + valid wab.json (no signature OR no telemetry) (limited execute)
12+
* 1 — Resolves but no _wab record / score below threshold (read-only)
13+
* 0 — Compliance verdict = deny / suspicious (block)
14+
*
15+
* Usage (Node):
16+
* const { WABSafeMode } = require('web-agent-bridge/sdk');
17+
* const safe = new WABSafeMode({ apiBase: 'https://webagentbridge.com' });
18+
* const v = await safe.evaluate('example.com');
19+
* if (v.allow_execute) await agent.execute(...);
20+
* else if (v.allow_read) await agent.readOnly(...);
21+
* else throw new Error('Blocked by Safe Mode: ' + v.reason);
22+
*/
23+
24+
'use strict';
25+
26+
const DEFAULT_API = 'https://webagentbridge.com';
27+
28+
const POLICIES = {
29+
strict: { require_dnssec: true, require_signature: true, min_score: 75 },
30+
standard: { require_dnssec: false, require_signature: true, min_score: 60 },
31+
permissive: { require_dnssec: false, require_signature: false, min_score: 40 },
32+
};
33+
34+
class WABSafeMode {
35+
/**
36+
* @param {object} [opts]
37+
* @param {string} [opts.apiBase='https://webagentbridge.com']
38+
* @param {'strict'|'standard'|'permissive'} [opts.policy='standard']
39+
* @param {number} [opts.cacheTtlMs=60000] — verdicts cached this long
40+
* @param {number} [opts.timeoutMs=8000]
41+
* @param {function} [opts.fetch] — fetch impl (defaults to global fetch)
42+
*/
43+
constructor(opts = {}) {
44+
this.apiBase = (opts.apiBase || DEFAULT_API).replace(/\/+$/, '');
45+
this.policy = POLICIES[opts.policy] ? opts.policy : 'standard';
46+
this.cacheTtl = Number.isFinite(opts.cacheTtlMs) ? opts.cacheTtlMs : 60_000;
47+
this.timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : 8_000;
48+
this._fetch = opts.fetch || (typeof fetch !== 'undefined' ? fetch : null);
49+
this._cache = new Map(); // domain → { at, value }
50+
if (!this._fetch) {
51+
// Node ≤ 17 fallback
52+
try { this._fetch = require('node-fetch'); } catch { /* user must supply */ }
53+
}
54+
}
55+
56+
/** Normalises a domain or URL to bare hostname. */
57+
static normalizeDomain(input) {
58+
if (!input || typeof input !== 'string') return null;
59+
let s = input.trim().toLowerCase();
60+
s = s.replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace(/^www\./, '');
61+
return /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/.test(s) ? s : null;
62+
}
63+
64+
async _get(path) {
65+
if (!this._fetch) throw new Error('Safe Mode requires fetch (Node 18+ or pass opts.fetch)');
66+
const ctl = (typeof AbortController !== 'undefined') ? new AbortController() : null;
67+
const timer = ctl ? setTimeout(() => ctl.abort(), this.timeoutMs) : null;
68+
try {
69+
const r = await this._fetch(this.apiBase + path, ctl ? { signal: ctl.signal } : {});
70+
if (!r.ok) return null;
71+
return await r.json();
72+
} catch { return null; }
73+
finally { if (timer) clearTimeout(timer); }
74+
}
75+
76+
/**
77+
* Evaluate a domain and produce a verdict the agent can act on.
78+
* @param {string} domain
79+
* @param {object} [opts]
80+
* @param {boolean} [opts.live=false] — force a live trust check (skip cache).
81+
* @returns {Promise<{
82+
* domain: string, level: 0|1|2|3,
83+
* verdict: 'allow'|'restrict'|'deny',
84+
* allow_execute: boolean, allow_read: boolean,
85+
* score: number, score_label: string,
86+
* reason: string, reasons: Array,
87+
* trust: object|null, score_detail: object|null,
88+
* compliance: object|null,
89+
* evaluated_at: string,
90+
* }>}
91+
*/
92+
async evaluate(domain, opts = {}) {
93+
const d = WABSafeMode.normalizeDomain(domain);
94+
if (!d) {
95+
return this._verdict(domain, 0, 'deny', 0, 'unrated',
96+
'invalid_domain', [{ code: 'invalid_domain', severity: 'deny' }],
97+
null, null, null);
98+
}
99+
100+
const cached = this._cache.get(d);
101+
if (!opts.live && cached && (Date.now() - cached.at) < this.cacheTtl) return cached.value;
102+
103+
// Optionally trigger a live trust check first so compliance has fresh data.
104+
let trust = null;
105+
if (opts.live) {
106+
trust = await this._get(`/api/discovery/trust/${encodeURIComponent(d)}`);
107+
}
108+
109+
const [score, compliance] = await Promise.all([
110+
this._get(`/api/discovery/score/${encodeURIComponent(d)}`),
111+
this._get(`/api/discovery/compliance/${encodeURIComponent(d)}?policy=${this.policy}`),
112+
]);
113+
114+
// Derive trust level
115+
let level = 1;
116+
let reasonCode = 'no_signal';
117+
if (compliance) {
118+
if (compliance.verdict === 'deny') { level = 0; reasonCode = 'compliance_deny'; }
119+
else if (compliance.verdict === 'restrict') { level = 1; reasonCode = 'compliance_restrict'; }
120+
else { // allow
121+
const sigRate = score?.signature_valid_rate ?? compliance.signature_valid_rate ?? 0;
122+
const sc = compliance.score ?? score?.score ?? 0;
123+
if (sigRate > 0.5 && sc >= 60) { level = 3; reasonCode = 'trusted_full'; }
124+
else { level = 2; reasonCode = 'trusted_limited'; }
125+
}
126+
} else {
127+
level = 1;
128+
reasonCode = 'no_compliance_record';
129+
}
130+
131+
const verdict = compliance?.verdict || (level === 0 ? 'deny' : level >= 2 ? 'allow' : 'restrict');
132+
const value = this._verdict(
133+
d, level, verdict,
134+
compliance?.score ?? score?.score ?? 0,
135+
compliance?.score_label ?? score?.label ?? 'unrated',
136+
reasonCode,
137+
compliance?.reasons || [],
138+
trust, score, compliance,
139+
);
140+
141+
this._cache.set(d, { at: Date.now(), value });
142+
return value;
143+
}
144+
145+
_verdict(domain, level, verdict, score, label, reason, reasons, trust, scoreDetail, compliance) {
146+
const allow_execute = level >= 2 && verdict === 'allow';
147+
const allow_read = level >= 1 && verdict !== 'deny';
148+
return {
149+
domain,
150+
level,
151+
verdict,
152+
allow_execute,
153+
allow_read,
154+
score,
155+
score_label: label,
156+
reason,
157+
reasons,
158+
trust,
159+
score_detail: scoreDetail,
160+
compliance,
161+
policy: this.policy,
162+
evaluated_at: new Date().toISOString(),
163+
};
164+
}
165+
166+
/**
167+
* Wrap an async action so it only runs if Safe Mode allows execute on the
168+
* given domain. Throws WABSafeModeError otherwise.
169+
*/
170+
async guardExecute(domain, action) {
171+
const v = await this.evaluate(domain);
172+
if (!v.allow_execute) {
173+
const err = new WABSafeModeError(
174+
`Safe Mode blocked execute on ${v.domain} (level ${v.level}, verdict ${v.verdict}, ${v.reason})`,
175+
v,
176+
);
177+
throw err;
178+
}
179+
return await action(v);
180+
}
181+
182+
/** Read-only variant: throws only if level === 0. */
183+
async guardRead(domain, action) {
184+
const v = await this.evaluate(domain);
185+
if (!v.allow_read) {
186+
throw new WABSafeModeError(
187+
`Safe Mode blocked read on ${v.domain} (level ${v.level}, verdict ${v.verdict})`,
188+
v,
189+
);
190+
}
191+
return await action(v);
192+
}
193+
194+
/** Picks the highest-trust domain from a candidate list. */
195+
async pickBest(domains) {
196+
const evals = await Promise.all(
197+
(domains || []).map((d) => this.evaluate(d).catch(() => null)),
198+
);
199+
const sorted = evals.filter(Boolean).sort((a, b) => {
200+
if (b.level !== a.level) return b.level - a.level;
201+
return (b.score || 0) - (a.score || 0);
202+
});
203+
return sorted[0] || null;
204+
}
205+
206+
clearCache(domain) {
207+
if (domain) this._cache.delete(WABSafeMode.normalizeDomain(domain));
208+
else this._cache.clear();
209+
}
210+
}
211+
212+
class WABSafeModeError extends Error {
213+
constructor(message, verdict) {
214+
super(message);
215+
this.name = 'WABSafeModeError';
216+
this.code = 'WAB_SAFE_MODE_BLOCKED';
217+
this.verdict = verdict;
218+
}
219+
}
220+
221+
module.exports = { WABSafeMode, WABSafeModeError, POLICIES };

0 commit comments

Comments
 (0)