shraga 0.1.112 → 0.1.113
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/README.md +2 -0
- package/defaults/mcps/README.md +6 -3
- package/defaults/skills/mcp-server.md +12 -5
- package/defaults/skills/platform.md +1 -1
- package/dist/client/assets/index-DIDtPQb-.css +10 -0
- package/dist/client/assets/index-DJ0AgGIu.js +1969 -0
- package/dist/client/index.html +2 -2
- package/package.json +3 -2
- package/src/client/App.tsx +33 -8
- package/src/client/components/BackendStatusBanner.tsx +62 -0
- package/src/client/components/ConfigPanel.tsx +61 -15
- package/src/client/components/ConversationHeader.tsx +5 -1
- package/src/client/components/McpManager.tsx +26 -9
- package/src/client/components/SkillsManager.tsx +48 -27
- package/src/client/hooks/useAuth.ts +11 -2
- package/src/client/hooks/useIsOwner.ts +24 -0
- package/src/client/hooks/useModules.ts +5 -1
- package/src/client/lib/api.ts +21 -5
- package/src/client/lib/backendHealth.ts +230 -0
- package/src/client/lib/debug.ts +48 -0
- package/src/client/lib/sessionApi.ts +24 -8
- package/src/client/lib/ws.ts +21 -13
- package/src/scripts/harden-audit.sh +55 -0
- package/src/server/api-key-routes.ts +64 -0
- package/src/server/api-keys.ts +181 -43
- package/src/server/auth.ts +113 -47
- package/src/server/boot.ts +158 -104
- package/src/server/claude.ts +98 -3
- package/src/server/data-sync.ts +55 -6
- package/src/server/directives.ts +2 -4
- package/src/server/engine/claude-code.ts +44 -14
- package/src/server/engine/types.ts +7 -0
- package/src/server/hooks.ts +19 -0
- package/src/server/mcp-oauth.ts +24 -5
- package/src/server/mcp-server.ts +55 -25
- package/src/server/modules/routes.ts +2 -6
- package/src/server/notify-owners.ts +5 -17
- package/src/server/owners.ts +14 -0
- package/src/server/scheduler/builtins.ts +3 -1
- package/src/server/scheduler/runner.ts +3 -0
- package/src/server/security/audit.ts +498 -0
- package/src/server/security/enforce.ts +306 -0
- package/src/server/security/escalate.ts +194 -0
- package/src/server/security/guard.ts +329 -0
- package/src/server/security/owner-only.ts +15 -0
- package/src/server/security/owner-routes.ts +43 -0
- package/src/server/security/policy.ts +413 -0
- package/src/server/security/principal.ts +80 -0
- package/src/server/security/revocation.ts +50 -0
- package/src/server/security/runtime.ts +174 -0
- package/src/server/sessions.ts +35 -0
- package/src/server/slack/bot.ts +44 -11
- package/src/server/slack/context-cache.ts +40 -7
- package/src/server/webhook-lane/feature.ts +17 -6
- package/src/shared/models.ts +11 -0
- package/dist/client/assets/index-DIMte_k6.css +0 -10
- package/dist/client/assets/index-Dc1ljSt3.js +0 -1949
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
// Policy: principal → role → capability profile. Loads data/security/policy.json, validates, compiles
|
|
2
|
+
// into Maps so resolve() is a few lookups with no I/O.
|
|
3
|
+
//
|
|
4
|
+
// Trust rules:
|
|
5
|
+
// - Owners come ONLY from the OWNERS env (owners.ts) — never from the file, so a file edit can't mint one.
|
|
6
|
+
// - Hot reload is PROVENANCE-CHECKED: only content whose sha256 matches what this process wrote via
|
|
7
|
+
// save() is loaded at runtime. Any other change keeps the last-good policy and fires onTamper.
|
|
8
|
+
// (The file present at boot is trusted — protecting it at rest is the protected-paths step.)
|
|
9
|
+
// - Missing file ⇒ migration (from whitelist.json + optional hook), ONCE: a `.migrated` marker is
|
|
10
|
+
// written beside it, and a missing file with the marker present is deletion ⇒ fail closed + onTamper.
|
|
11
|
+
// - Empty/invalid file, or migration/IO failure ⇒ fail closed: only owners resolve above anonymous.
|
|
12
|
+
// - PASSIVE (`isActive` false: a standby sharing DATA_DIR) never writes: no migration, no marker, save() throws;
|
|
13
|
+
// a missing file fails closed in memory and hot-reload/tamper checks are off. activate() re-loads as at boot.
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, watch, writeFileSync, type FSWatcher } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { dataPath } from '../paths.ts';
|
|
18
|
+
import { isOwnerEmail } from '../owners.ts';
|
|
19
|
+
import { fromAuthUser, isSystemPrincipal, type Principal, type PrincipalKind } from './principal.ts';
|
|
20
|
+
|
|
21
|
+
export type ReadScope = 'all' | 'own' | 'none';
|
|
22
|
+
export interface Profile { tools: string[]; mcps: string[]; env: string[]; outbound: boolean; readScope: ReadScope; rate: string }
|
|
23
|
+
export interface RoleDef { rank: number; profile: string }
|
|
24
|
+
export interface BindingMatch { kind?: PrincipalKind; id?: string; emailIn?: string[]; domain?: string; verified?: boolean }
|
|
25
|
+
export interface Binding { match: BindingMatch; role: string }
|
|
26
|
+
export interface BlockEntry { match: BindingMatch; until: number | null; reason?: string }
|
|
27
|
+
export interface PolicyFile {
|
|
28
|
+
roles: Record<string, RoleDef>;
|
|
29
|
+
profiles: Record<string, Profile>;
|
|
30
|
+
bindings: Binding[];
|
|
31
|
+
default: string;
|
|
32
|
+
blocklist: BlockEntry[];
|
|
33
|
+
/** principalId → epoch seconds; tokens issued before are invalid. */
|
|
34
|
+
tokensValidAfter: Record<string, number>;
|
|
35
|
+
}
|
|
36
|
+
export interface Resolved { role: string; rank: number; profile: Profile; profileName: string }
|
|
37
|
+
export type TamperReason = 'hash-mismatch' | 'deleted';
|
|
38
|
+
|
|
39
|
+
export const OWNER_ROLE = 'owner';
|
|
40
|
+
/** Role of no-human system lanes (see resolve). */
|
|
41
|
+
export const OPERATOR_ROLE = 'operator';
|
|
42
|
+
const NONE_PROFILE: Profile = { tools: [], mcps: [], env: [], outbound: false, readScope: 'none', rate: '0' };
|
|
43
|
+
const FULL_PROFILE: Profile = { tools: ['*'], mcps: ['*'], env: ['*'], outbound: true, readScope: 'all', rate: '600/h' };
|
|
44
|
+
|
|
45
|
+
/** Defaults from the plan. Migration seeds bindings on top of this. */
|
|
46
|
+
export function defaultPolicy(): PolicyFile {
|
|
47
|
+
return {
|
|
48
|
+
roles: {
|
|
49
|
+
owner: { rank: 100, profile: 'full' },
|
|
50
|
+
operator: { rank: 80, profile: 'full' },
|
|
51
|
+
member: { rank: 50, profile: 'standard' },
|
|
52
|
+
guest: { rank: 20, profile: 'reply-only' },
|
|
53
|
+
anonymous: { rank: 0, profile: 'none' },
|
|
54
|
+
},
|
|
55
|
+
profiles: {
|
|
56
|
+
full: { ...FULL_PROFILE },
|
|
57
|
+
standard: { tools: ['Read', 'Glob', 'LS', 'WebSearch'], mcps: [], env: [], outbound: true, readScope: 'own', rate: '120/h' },
|
|
58
|
+
'reply-only': { tools: ['escalate'], mcps: [], env: [], outbound: true, readScope: 'own', rate: '10/h' },
|
|
59
|
+
none: { ...NONE_PROFILE },
|
|
60
|
+
},
|
|
61
|
+
bindings: [],
|
|
62
|
+
default: 'anonymous',
|
|
63
|
+
blocklist: [],
|
|
64
|
+
tokensValidAfter: {},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const RATE_RE = /^\d+(\/[smhd])?$/;
|
|
69
|
+
const KINDS = new Set(['user', 'email', 'slack', 'apikey', 'internal', 'anonymous']);
|
|
70
|
+
const strArr = (v: unknown) => Array.isArray(v) && v.every(x => typeof x === 'string');
|
|
71
|
+
|
|
72
|
+
function validateMatch(m: any, where: string, errs: string[]) {
|
|
73
|
+
if (!m || typeof m !== 'object' || Array.isArray(m)) return void errs.push(`${where}.match must be an object`);
|
|
74
|
+
const keys = Object.keys(m);
|
|
75
|
+
if (!keys.length) errs.push(`${where}.match is empty (would match everyone)`);
|
|
76
|
+
for (const k of keys) if (!['kind', 'id', 'emailIn', 'domain', 'verified'].includes(k)) errs.push(`${where}.match.${k} unknown`);
|
|
77
|
+
if (m.kind !== undefined && !KINDS.has(m.kind)) errs.push(`${where}.match.kind invalid`);
|
|
78
|
+
if (m.emailIn !== undefined && !strArr(m.emailIn)) errs.push(`${where}.match.emailIn must be string[]`);
|
|
79
|
+
if (m.id !== undefined && typeof m.id !== 'string') errs.push(`${where}.match.id must be string`);
|
|
80
|
+
if (m.domain !== undefined && typeof m.domain !== 'string') errs.push(`${where}.match.domain must be string`);
|
|
81
|
+
if (m.verified !== undefined && typeof m.verified !== 'boolean') errs.push(`${where}.match.verified must be boolean`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Returns a list of errors; empty = valid. */
|
|
85
|
+
export function validatePolicy(p: any): string[] {
|
|
86
|
+
const errs: string[] = [];
|
|
87
|
+
if (!p || typeof p !== 'object' || Array.isArray(p)) return ['policy must be an object'];
|
|
88
|
+
const roles = p.roles, profiles = p.profiles;
|
|
89
|
+
if (!roles || typeof roles !== 'object' || !Object.keys(roles).length) errs.push('roles missing/empty');
|
|
90
|
+
if (!profiles || typeof profiles !== 'object' || !Object.keys(profiles).length) errs.push('profiles missing/empty');
|
|
91
|
+
if (errs.length) return errs;
|
|
92
|
+
for (const [name, pr] of Object.entries<any>(profiles)) {
|
|
93
|
+
for (const f of ['tools', 'mcps', 'env']) if (!strArr(pr?.[f])) errs.push(`profiles.${name}.${f} must be string[]`);
|
|
94
|
+
if (typeof pr?.outbound !== 'boolean') errs.push(`profiles.${name}.outbound must be boolean`);
|
|
95
|
+
if (!['all', 'own', 'none'].includes(pr?.readScope)) errs.push(`profiles.${name}.readScope invalid`);
|
|
96
|
+
if (typeof pr?.rate !== 'string' || !RATE_RE.test(pr.rate)) errs.push(`profiles.${name}.rate invalid (e.g. "120/h")`);
|
|
97
|
+
}
|
|
98
|
+
// owner is env-rooted; its entry only picks rank/profile — but both are validated like any role
|
|
99
|
+
for (const [name, r] of Object.entries<any>(roles)) {
|
|
100
|
+
if (typeof r?.rank !== 'number' || !Number.isFinite(r.rank)) errs.push(`roles.${name}.rank must be a finite number`);
|
|
101
|
+
if (!profiles[r?.profile]) errs.push(`roles.${name}.profile "${r?.profile}" not defined`);
|
|
102
|
+
}
|
|
103
|
+
const ownerRank = roles[OWNER_ROLE]?.rank ?? 100;
|
|
104
|
+
if (Number.isFinite(ownerRank)) {
|
|
105
|
+
for (const [name, r] of Object.entries<any>(roles)) if (name !== OWNER_ROLE && r?.rank >= ownerRank) errs.push(`roles.${name}.rank must be below owner (${ownerRank})`);
|
|
106
|
+
}
|
|
107
|
+
if (!Array.isArray(p.bindings)) errs.push('bindings must be an array');
|
|
108
|
+
else p.bindings.forEach((b: any, i: number) => {
|
|
109
|
+
validateMatch(b?.match, `bindings[${i}]`, errs);
|
|
110
|
+
if (!roles[b?.role]) errs.push(`bindings[${i}].role "${b?.role}" not defined`);
|
|
111
|
+
if (b?.role === OWNER_ROLE) errs.push(`bindings[${i}] cannot grant owner (OWNERS env only)`);
|
|
112
|
+
});
|
|
113
|
+
if (!roles[p.default]) errs.push(`default role "${p.default}" not defined`);
|
|
114
|
+
if (p.default === OWNER_ROLE) errs.push('default cannot be owner');
|
|
115
|
+
if (p.blocklist !== undefined) {
|
|
116
|
+
if (!Array.isArray(p.blocklist)) errs.push('blocklist must be an array');
|
|
117
|
+
else p.blocklist.forEach((b: any, i: number) => {
|
|
118
|
+
validateMatch(b?.match, `blocklist[${i}]`, errs);
|
|
119
|
+
if (b?.until !== null && b?.until !== undefined && typeof b.until !== 'number') errs.push(`blocklist[${i}].until must be number|null`);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (p.tokensValidAfter !== undefined && (typeof p.tokensValidAfter !== 'object' || Array.isArray(p.tokensValidAfter)
|
|
123
|
+
|| !Object.values(p.tokensValidAfter).every(v => typeof v === 'number'))) errs.push('tokensValidAfter must be Record<string, number>');
|
|
124
|
+
return errs;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function matches(m: BindingMatch, p: Principal): boolean {
|
|
128
|
+
if (m.kind !== undefined && m.kind !== p.kind) return false;
|
|
129
|
+
if (m.id !== undefined && m.id !== p.id) return false;
|
|
130
|
+
if (m.verified !== undefined && m.verified !== p.verified) return false;
|
|
131
|
+
if (m.domain !== undefined && m.domain.toLowerCase() !== p.domain) return false;
|
|
132
|
+
if (m.emailIn !== undefined && !(p.email && m.emailIn.some(e => e.toLowerCase() === p.email))) return false;
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface Compiled {
|
|
137
|
+
file: PolicyFile;
|
|
138
|
+
roles: Map<string, RoleDef>;
|
|
139
|
+
profiles: Map<string, Profile>;
|
|
140
|
+
/** Roles sorted by rank desc — for effective(). */
|
|
141
|
+
byRank: [string, RoleDef][];
|
|
142
|
+
/** email → binding indexes that name it (emailIn); domain → indexes; rest = bindings keyed by neither. */
|
|
143
|
+
byEmail: Map<string, number[]>;
|
|
144
|
+
byDomain: Map<string, number[]>;
|
|
145
|
+
generic: number[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function compile(file: PolicyFile): Compiled {
|
|
149
|
+
const byEmail = new Map<string, number[]>(), byDomain = new Map<string, number[]>(), generic: number[] = [];
|
|
150
|
+
const push = (m: Map<string, number[]>, k: string, i: number) => { const l = m.get(k); l ? l.push(i) : m.set(k, [i]); };
|
|
151
|
+
file.bindings.forEach((b, i) => {
|
|
152
|
+
if (b.match.emailIn) b.match.emailIn.forEach(e => push(byEmail, e.toLowerCase(), i));
|
|
153
|
+
else if (b.match.domain) push(byDomain, b.match.domain.toLowerCase(), i);
|
|
154
|
+
else generic.push(i);
|
|
155
|
+
});
|
|
156
|
+
const roles = new Map(Object.entries(file.roles));
|
|
157
|
+
if (!roles.has(OWNER_ROLE)) roles.set(OWNER_ROLE, { rank: 100, profile: 'full' });
|
|
158
|
+
const profiles = new Map(Object.entries(file.profiles));
|
|
159
|
+
if (!profiles.has(roles.get(OWNER_ROLE)!.profile)) profiles.set(roles.get(OWNER_ROLE)!.profile, { ...FULL_PROFILE });
|
|
160
|
+
return { file, roles, profiles, byRank: [...roles].sort((a, b) => b[1].rank - a[1].rank), byEmail, byDomain, generic };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The fail-closed policy: owner (env) + anonymous, nothing else. */
|
|
164
|
+
function failClosed(): Compiled {
|
|
165
|
+
return compile({
|
|
166
|
+
roles: { owner: { rank: 100, profile: 'full' }, anonymous: { rank: 0, profile: 'none' } },
|
|
167
|
+
profiles: { full: { ...FULL_PROFILE }, none: { ...NONE_PROFILE } },
|
|
168
|
+
bindings: [], default: 'anonymous', blocklist: [], tokensValidAfter: {},
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
|
|
173
|
+
const serialize = (p: PolicyFile) => JSON.stringify(p, null, 2) + '\n';
|
|
174
|
+
|
|
175
|
+
export class PolicyOptions {
|
|
176
|
+
/** Policy file location. */
|
|
177
|
+
path: string = dataPath('security', 'policy.json');
|
|
178
|
+
/** Legacy whitelist (string[] of emails) migrated into operator bindings when policy.json is missing. */
|
|
179
|
+
whitelistPath: string = dataPath('whitelist.json');
|
|
180
|
+
/** Consumer hook to extend the migrated policy (e.g. operators from a contacts store). */
|
|
181
|
+
migrate?: (draft: PolicyFile) => PolicyFile | void;
|
|
182
|
+
/** Called when an on-disk change was NOT made by save() and was rejected. */
|
|
183
|
+
onTamper?: (info: { path: string; reason: TamperReason; expected: string | null; actual: string | null }) => void;
|
|
184
|
+
/** Watch the file for hot reload. */
|
|
185
|
+
watch: boolean = true;
|
|
186
|
+
/** False on a PASSIVE standby sharing DATA_DIR: no writes (no migration, marker or save) and no tamper checks. */
|
|
187
|
+
isActive: () => boolean = () => true;
|
|
188
|
+
log: Pick<Console, 'info' | 'warn' | 'error'> = console;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export class Policy {
|
|
192
|
+
public options: PolicyOptions;
|
|
193
|
+
private compiled: Compiled = failClosed();
|
|
194
|
+
/** sha256 of the content currently trusted (loaded at boot or written by save()). */
|
|
195
|
+
private trustedHash: string | null = null;
|
|
196
|
+
private watcher?: FSWatcher;
|
|
197
|
+
private _valid = false;
|
|
198
|
+
|
|
199
|
+
public constructor(options?: Partial<PolicyOptions>) {
|
|
200
|
+
this.options = { ...new PolicyOptions(), ...options };
|
|
201
|
+
this.load();
|
|
202
|
+
if (this.options.watch && this.active()) this.startWatch(); // passive: events are ignored and the dir may not exist yet
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** True when a valid policy is loaded (false = fail-closed mode). */
|
|
206
|
+
public get valid(): boolean { return this._valid; }
|
|
207
|
+
public get current(): PolicyFile { return structuredClone(this.compiled.file); }
|
|
208
|
+
|
|
209
|
+
/** Marker proving migration already ran once — a later missing policy.json is deletion, not first boot. */
|
|
210
|
+
private get markerPath(): string { return path.join(path.dirname(this.options.path), '.migrated'); }
|
|
211
|
+
|
|
212
|
+
private failClosedNow(msg: string): void {
|
|
213
|
+
this.options.log.error(`[policy] ${msg} — failing closed (owners only)`);
|
|
214
|
+
this.compiled = failClosed(); this._valid = false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Initial load: migrate once if missing; trust what's on disk; fail closed if invalid. Never throws. */
|
|
218
|
+
private load(): void {
|
|
219
|
+
const { path: p, log } = this.options;
|
|
220
|
+
try {
|
|
221
|
+
if (!existsSync(p)) {
|
|
222
|
+
if (!this.active()) return this.failClosedNow(`${p} missing while PASSIVE — not migrating (the active instance owns data/)`);
|
|
223
|
+
if (existsSync(this.markerPath)) {
|
|
224
|
+
this.failClosedNow(`TAMPER: ${p} missing after migration (${this.markerPath} exists) — not re-migrating`);
|
|
225
|
+
try { this.options.onTamper?.({ path: p, reason: 'deleted', expected: null, actual: null }); }
|
|
226
|
+
catch (e: any) { log.error(`[policy] onTamper threw: ${e.message}`); }
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
this.save(this.migrate());
|
|
230
|
+
log.info(`[policy] migrated → ${p}`);
|
|
231
|
+
this.ensureMarker();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const raw = readFileSync(p, 'utf8');
|
|
235
|
+
this.trustedHash = sha256(raw);
|
|
236
|
+
this.apply(raw);
|
|
237
|
+
if (this.active()) this.ensureMarker(); // heals a marker write that failed on an earlier boot
|
|
238
|
+
} catch (e: any) {
|
|
239
|
+
if (!existsSync(p)) this.trustedHash = null; // save() recorded a hash it never wrote
|
|
240
|
+
this.failClosedNow(`load/migration failed: ${e.message}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Write the `.migrated` marker if missing. Failure only logs — it never affects the loaded policy. */
|
|
245
|
+
private ensureMarker(): void {
|
|
246
|
+
try {
|
|
247
|
+
if (!existsSync(this.markerPath)) writeFileSync(this.markerPath, `${new Date().toISOString()}\n`, { mode: 0o600 });
|
|
248
|
+
} catch (e: any) { this.options.log.error(`[policy] marker write failed (${this.markerPath}): ${e.message}`); }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private apply(raw: string): boolean {
|
|
252
|
+
let parsed: any;
|
|
253
|
+
try { parsed = raw.trim() ? JSON.parse(raw) : null; } catch (e: any) { parsed = undefined; this.options.log.error(`[policy] parse failed: ${e.message}`); }
|
|
254
|
+
const errs = parsed ? validatePolicy(parsed) : ['policy file is empty or unparseable'];
|
|
255
|
+
if (errs.length) {
|
|
256
|
+
this.options.log.error(`[policy] INVALID — failing closed (owners only): ${errs.join('; ')}`);
|
|
257
|
+
this.compiled = failClosed(); this._valid = false;
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
this.compiled = compile({ blocklist: [], tokensValidAfter: {}, ...parsed });
|
|
261
|
+
this._valid = true;
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Build the initial policy from the legacy whitelist + consumer hook. */
|
|
266
|
+
public migrate(): PolicyFile {
|
|
267
|
+
let draft = defaultPolicy();
|
|
268
|
+
try {
|
|
269
|
+
if (existsSync(this.options.whitelistPath)) {
|
|
270
|
+
const list = JSON.parse(readFileSync(this.options.whitelistPath, 'utf8'));
|
|
271
|
+
const emails = Array.isArray(list) ? list.filter((x): x is string => typeof x === 'string').map(e => e.trim().toLowerCase()).filter(Boolean) : [];
|
|
272
|
+
if (emails.length) draft.bindings.push({ match: { kind: 'user', emailIn: emails }, role: 'operator' });
|
|
273
|
+
}
|
|
274
|
+
} catch (e: any) { this.options.log.error(`[policy] whitelist migration failed: ${e.message}`); }
|
|
275
|
+
if (this.options.migrate) draft = this.options.migrate(draft) ?? draft;
|
|
276
|
+
return draft;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The ONLY trusted writer. Validates, writes atomically, records provenance, applies. */
|
|
280
|
+
public save(next: PolicyFile): void {
|
|
281
|
+
if (!this.active()) {
|
|
282
|
+
const msg = `save refused: PASSIVE standby must not write ${this.options.path}`;
|
|
283
|
+
this.options.log.error(`[policy] ${msg}`);
|
|
284
|
+
throw new Error(msg);
|
|
285
|
+
}
|
|
286
|
+
const errs = validatePolicy(next);
|
|
287
|
+
if (errs.length) throw new Error(`invalid policy: ${errs.join('; ')}`);
|
|
288
|
+
const content = serialize(next);
|
|
289
|
+
const p = this.options.path;
|
|
290
|
+
mkdirSync(path.dirname(p), { recursive: true });
|
|
291
|
+
this.trustedHash = sha256(content); // before the write, so the watcher sees a match
|
|
292
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
293
|
+
writeFileSync(tmp, content, { mode: 0o600 });
|
|
294
|
+
renameSync(tmp, p);
|
|
295
|
+
this.apply(content);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Re-read disk; load only if it's content we wrote. Returns whether the disk state is trusted. */
|
|
299
|
+
public reload(): boolean {
|
|
300
|
+
// PASSIVE: the active instance legitimately saves this file; a standby would read that as tamper. Ignore.
|
|
301
|
+
if (!this.active()) return false;
|
|
302
|
+
const p = this.options.path;
|
|
303
|
+
const raw = existsSync(p) ? readFileSync(p, 'utf8') : null;
|
|
304
|
+
const actual = raw === null ? null : sha256(raw);
|
|
305
|
+
if (actual === this.trustedHash) return true; // our own write (or no-op touch) — already applied
|
|
306
|
+
const reason: TamperReason = raw === null ? 'deleted' : 'hash-mismatch';
|
|
307
|
+
this.options.log.error(`[policy] TAMPER: ${p} changed outside save() (${reason}) — keeping last-good policy`);
|
|
308
|
+
try { this.options.onTamper?.({ path: p, reason, expected: this.trustedHash, actual }); }
|
|
309
|
+
catch (e: any) { this.options.log.error(`[policy] onTamper threw: ${e.message}`); }
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
private startWatch(): void {
|
|
314
|
+
const dir = path.dirname(this.options.path), base = path.basename(this.options.path);
|
|
315
|
+
let t: ReturnType<typeof setTimeout> | undefined;
|
|
316
|
+
try {
|
|
317
|
+
this.watcher = watch(dir, (_ev, f) => {
|
|
318
|
+
if (f && f.toString() !== base) return;
|
|
319
|
+
clearTimeout(t);
|
|
320
|
+
t = setTimeout(() => this.reload(), 50);
|
|
321
|
+
});
|
|
322
|
+
this.watcher.unref?.();
|
|
323
|
+
} catch (e: any) { this.options.log.error(`[policy] watch failed: ${e.message}`); }
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
public close(): void { this.watcher?.close(); this.watcher = undefined; }
|
|
327
|
+
|
|
328
|
+
private active(): boolean {
|
|
329
|
+
try { return this.options.isActive(); }
|
|
330
|
+
catch (e: any) { this.options.log.error(`[policy] isActive threw — treating as PASSIVE: ${e.message}`); return false; }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Passive → active promotion: re-load from disk with the same trust as boot (migrating now if still missing). */
|
|
334
|
+
public activate(): void {
|
|
335
|
+
this.load();
|
|
336
|
+
if (this.options.watch && !this.watcher) this.startWatch();
|
|
337
|
+
this.options.log.info(`[policy] activated — loaded ${this.options.path} (valid=${this._valid})`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private resolved(role: string): Resolved {
|
|
341
|
+
const c = this.compiled;
|
|
342
|
+
const def = c.roles.get(role) ?? c.roles.get(c.file.default) ?? { rank: 0, profile: 'none' };
|
|
343
|
+
const name = c.roles.has(role) ? role : c.file.default;
|
|
344
|
+
return { role: name, rank: def.rank, profileName: def.profile, profile: c.profiles.get(def.profile) ?? NONE_PROFILE };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* principal → role/profile. First rule that applies:
|
|
349
|
+
* 1. SYSTEM LANE (`isSystemPrincipal`: built-in/module schedules, legacy raw internal token) → `operator`.
|
|
350
|
+
* No human is behind it; the deployment itself runs it. (Fail-closed policy has no operator → default.)
|
|
351
|
+
* 2. OWNER (OWNERS env) — only for an authenticated login (`user`) or an `internal` principal acting FOR a user
|
|
352
|
+
* (wake, web-retry, scheduler with a user `createdBy`, slack-retry, the agent's scoped token). That run was started
|
|
353
|
+
* by that authenticated user, so it re-resolves exactly as their login would NOW: a revoked/downgraded creator
|
|
354
|
+
* runs with their current role. The session floor (taint) still caps it at turn time.
|
|
355
|
+
* A DKIM-verified email, a Slack sender or an api key carrying an owner address is NOT owner.
|
|
356
|
+
* 3. First matching binding. `internal` and `slack` principals with an email ALSO match bindings as that email's
|
|
357
|
+
* verified login would (`kind:"user"` view) — so Slack senders resolve through the same email bindings as the
|
|
358
|
+
* web, never to owner. Their own-kind bindings (`kind:"slack"`, id) still apply; the earliest binding wins.
|
|
359
|
+
* 4. Default role.
|
|
360
|
+
*/
|
|
361
|
+
public resolve(p: Principal): Resolved {
|
|
362
|
+
if (isSystemPrincipal(p)) return this.resolved(OPERATOR_ROLE);
|
|
363
|
+
if ((p.kind === 'user' || p.kind === 'internal') && p.verified && p.email && isOwnerEmail(p.email)) return this.resolved(OWNER_ROLE);
|
|
364
|
+
const asUser = (p.kind === 'internal' || p.kind === 'slack') && p.verified && p.email
|
|
365
|
+
? fromAuthUser({ uid: String(p.attrs.uid ?? p.attrs.slackUserId ?? p.email), email: p.email }) : undefined;
|
|
366
|
+
const best = Math.min(this.firstBinding(p), asUser ? this.firstBinding(asUser) : Infinity);
|
|
367
|
+
return this.resolved(best === Infinity ? this.compiled.file.default : this.compiled.file.bindings[best].role);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Index of the first binding matching `p`, or Infinity. */
|
|
371
|
+
private firstBinding(p: Principal): number {
|
|
372
|
+
const c = this.compiled, b = c.file.bindings;
|
|
373
|
+
let best = Infinity;
|
|
374
|
+
const scan = (idx?: number[]) => { if (idx) for (const i of idx) { if (i >= best) break; if (matches(b[i].match, p)) { best = i; break; } } };
|
|
375
|
+
if (p.email) scan(c.byEmail.get(p.email));
|
|
376
|
+
if (p.domain) scan(c.byDomain.get(p.domain));
|
|
377
|
+
scan(c.generic);
|
|
378
|
+
return best;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The highest-ranked role strictly below owner (else the default) — the ceiling for a non-interactive principal. */
|
|
382
|
+
public belowOwner(): Resolved {
|
|
383
|
+
const ownerRank = this.resolved(OWNER_ROLE).rank;
|
|
384
|
+
const top = this.compiled.byRank.find(([name, d]) => name !== OWNER_ROLE && d.rank < ownerRank);
|
|
385
|
+
return this.resolved(top ? top[0] : this.compiled.file.default);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Taint: a session's effective role is the lowest-ranked role that contributed. */
|
|
389
|
+
public effective(floorRank: number, role: string): Resolved {
|
|
390
|
+
const r = this.resolved(role);
|
|
391
|
+
if (r.rank <= floorRank) return r;
|
|
392
|
+
const lower = this.compiled.byRank.find(([, d]) => d.rank <= floorRank);
|
|
393
|
+
return lower ? this.resolved(lower[0]) : this.resolved(this.compiled.byRank[this.compiled.byRank.length - 1][0]);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Active blocklist entry for this principal, if any. */
|
|
397
|
+
public blocked(p: Principal, now = Date.now() / 1000): BlockEntry | undefined {
|
|
398
|
+
return this.compiled.file.blocklist.find(e => (e.until === null || e.until > now) && matches(e.match, p));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
public tokensValidAfter(principalId: string): number | undefined {
|
|
402
|
+
return this.compiled.file.tokensValidAfter[principalId];
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** Set `tokensValidAfter[principalId]` (epoch seconds) through save(). Refuses in fail-closed mode: saving then
|
|
406
|
+
* would overwrite the broken-but-fixable file on disk with the owners-only fallback. */
|
|
407
|
+
public setTokensValidAfter(principalId: string, epochSec: number): void {
|
|
408
|
+
if (!this._valid) throw new Error('policy is invalid (fail-closed) — fix policy.json before revoking');
|
|
409
|
+
const next = this.current;
|
|
410
|
+
next.tokensValidAfter = { ...next.tokensValidAfter, [principalId]: epochSec };
|
|
411
|
+
this.save(next);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Principal = WHO is calling, normalized across channels. Pure + sync: no I/O, no imports of auth
|
|
2
|
+
// (auth.ts has boot side effects). Adapters take the structural shapes the channels already hold.
|
|
3
|
+
|
|
4
|
+
export type PrincipalKind = 'user' | 'email' | 'slack' | 'apikey' | 'internal' | 'anonymous';
|
|
5
|
+
|
|
6
|
+
export interface Principal {
|
|
7
|
+
/** Stable key, `<kind>:<identifier>` — used for rate buckets, audit, tokensValidAfter. */
|
|
8
|
+
id: string;
|
|
9
|
+
kind: PrincipalKind;
|
|
10
|
+
email?: string;
|
|
11
|
+
domain?: string;
|
|
12
|
+
/** Identity proven by the channel (signed token, DKIM+DMARC, Slack signature…). */
|
|
13
|
+
verified: boolean;
|
|
14
|
+
attrs: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function normalizeEmail(email?: string | null): string | undefined {
|
|
18
|
+
const e = String(email ?? '').trim().toLowerCase();
|
|
19
|
+
return e.includes('@') ? e : undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function emailDomain(email?: string | null): string | undefined {
|
|
23
|
+
const e = normalizeEmail(email);
|
|
24
|
+
return e ? e.slice(e.lastIndexOf('@') + 1) : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function make(kind: PrincipalKind, key: string, verified: boolean, email?: string | null, attrs: Record<string, unknown> = {}): Principal {
|
|
28
|
+
const e = normalizeEmail(email);
|
|
29
|
+
return { id: `${kind}:${key}`, kind, email: e, domain: emailDomain(e), verified, attrs };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Authenticated web/MCP/local user (AuthUser shape). The token was verified upstream. */
|
|
33
|
+
export function fromAuthUser(u: { uid: string; email?: string | null }): Principal {
|
|
34
|
+
const e = normalizeEmail(u.email);
|
|
35
|
+
return make('user', e ?? u.uid, true, e, { uid: u.uid });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Inbound email sender. `verified` = DKIM+DMARC aligned to the From domain (computed by the channel). */
|
|
39
|
+
export function fromEmailSender(from: string, verified: boolean, attrs: Record<string, unknown> = {}): Principal {
|
|
40
|
+
const e = normalizeEmail(from) ?? String(from).trim().toLowerCase();
|
|
41
|
+
return make('email', e, verified, e, attrs);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Slack sender (signature-verified event). Email optional (from contacts / users.info). */
|
|
45
|
+
export function fromSlack(slackUserId: string, opts: { email?: string | null; teamId?: string } = {}): Principal {
|
|
46
|
+
return make('slack', slackUserId, true, opts.email, { slackUserId, ...(opts.teamId ? { teamId: opts.teamId } : {}) });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** API key holder (validateApiKey shape). */
|
|
50
|
+
export function fromApiKey(k: { id?: string; uid: string; email?: string | null }): Principal {
|
|
51
|
+
return make('apikey', k.id ?? k.uid, true, k.email, { uid: k.uid });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Scoped internal token (agent subprocess acting for a user), or a no-human run (`lane`: wake, scheduler, retry…). */
|
|
55
|
+
export function fromInternal(t: { uid: string; email?: string | null; lane?: string }): Principal {
|
|
56
|
+
return make('internal', t.uid, true, t.email, { uid: t.uid, ...(t.lane ? { lane: t.lane } : {}) });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function anonymous(attrs: Record<string, unknown> = {}): Principal {
|
|
60
|
+
return { id: 'anonymous', kind: 'anonymous', verified: false, attrs };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ── No-human lanes ─────────────────────────────────────────────────────────────
|
|
64
|
+
// A system lane runs for the deployment itself, not for a person: built-in schedules (scheduler/builtins.ts),
|
|
65
|
+
// module schedules (modules/service.ts) and the legacy raw INTERNAL_API_TOKEN caller. They resolve to `operator`
|
|
66
|
+
// (policy.ts). BOTH the uid shape AND the lane's fixed identity email must match: a uid alone is not enough, since
|
|
67
|
+
// a login/signup could pick an id that looks like `module:x` — but it can't also carry the lane's email as its uid
|
|
68
|
+
// (local uid = the email itself; Firebase uids are opaque).
|
|
69
|
+
export const SYSTEM_UID = '__system__';
|
|
70
|
+
const SYSTEM_LANES: { uid: (uid: string) => boolean; email: string }[] = [
|
|
71
|
+
{ uid: (u) => u === SYSTEM_UID, email: 'system@shraga.local' },
|
|
72
|
+
{ uid: (u) => u.startsWith('module:') && u.length > 'module:'.length, email: 'module@shraga.local' },
|
|
73
|
+
{ uid: (u) => u === 'agent-internal', email: 'agent@internal' },
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
export function isSystemPrincipal(p: Principal): boolean {
|
|
77
|
+
if (p.kind !== 'internal') return false;
|
|
78
|
+
const uid = String(p.attrs.uid ?? '');
|
|
79
|
+
return SYSTEM_LANES.some((l) => l.uid(uid) && p.email === l.email);
|
|
80
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Token revocation: `policy.tokensValidAfter[principalId]` (epoch seconds). A token whose issued-at is BEFORE it is
|
|
2
|
+
// rejected. The check is one object lookup on the compiled policy — no I/O per request.
|
|
3
|
+
//
|
|
4
|
+
// Issued-at per token format (auth.ts):
|
|
5
|
+
// - local `sha_` / MCP `mcp_` / scoped internal tokens carry `iat` since this step. Tokens minted earlier have none:
|
|
6
|
+
// local + MCP ones are treated as issued at `exp - TTL` (their TTL is fixed), scoped internal ones (no exp) at 0,
|
|
7
|
+
// so ANY revocation of that principal kills them.
|
|
8
|
+
// - Firebase ID tokens: `auth_time` (the sign-in time — `iat` is refreshed hourly and would survive revocation).
|
|
9
|
+
// Granularity is one second: a token minted in the same second as, but before, the revocation still passes.
|
|
10
|
+
import { fromAuthUser, fromInternal } from './principal.ts';
|
|
11
|
+
import { security } from './runtime.ts';
|
|
12
|
+
|
|
13
|
+
/** Canonical id for a revocable principal (`user:` / `internal:` — the kinds a token verifier checks), built by the
|
|
14
|
+
* same principal builders the verifiers use (emails lowercased). Other kinds ⇒ null: nothing would ever check them. */
|
|
15
|
+
export function revocablePrincipalId(input: string): string | null {
|
|
16
|
+
const s = String(input ?? '').trim();
|
|
17
|
+
if (/[\p{Cc}\p{Cf}\p{Z}]/u.test(s)) return null; // invisible/control/space chars (e.g. U+200B) would never match a verifier's id
|
|
18
|
+
const m = /^(user|internal):(\S+)$/.exec(s);
|
|
19
|
+
if (!m) return null;
|
|
20
|
+
return m[1] === 'user' ? fromAuthUser({ uid: m[2], email: m[2] }).id : fromInternal({ uid: m[2] }).id;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** True when any of `principalIds` has `tokensValidAfter` later than `issuedAtSec`. Uninitialized runtime ⇒ false. */
|
|
24
|
+
export function tokenRevoked(principalIds: string | string[], issuedAtSec: number): boolean {
|
|
25
|
+
const policy = security()?.policy;
|
|
26
|
+
if (!policy) return false;
|
|
27
|
+
for (const id of Array.isArray(principalIds) ? principalIds : [principalIds]) {
|
|
28
|
+
const after = policy.tokensValidAfter(id);
|
|
29
|
+
if (after !== undefined && issuedAtSec < after) return true;
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Issued-at of a verified Firebase ID token payload, for revocation: sign-in time, falling back to iat, else 0. */
|
|
35
|
+
export function firebaseIssuedAt(payload: { auth_time?: unknown; iat?: unknown }): number {
|
|
36
|
+
const t = Number(payload.auth_time ?? payload.iat ?? 0);
|
|
37
|
+
return Number.isFinite(t) ? t : 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Invalidate every token issued to `principalId` until now. `actor` = who did it (principal id), for the audit.
|
|
41
|
+
* Throws when the runtime is missing, PASSIVE, or the policy is invalid. Returns the new epoch (seconds). */
|
|
42
|
+
export function revokeTokens(principalId: string, actor?: string, now = Date.now()): number {
|
|
43
|
+
const sec = security();
|
|
44
|
+
if (!sec) throw new Error('security runtime not initialized');
|
|
45
|
+
const validAfter = Math.floor(now / 1000);
|
|
46
|
+
sec.policy.setTokensValidAfter(principalId, validAfter);
|
|
47
|
+
sec.record({ type: 'token.revoke', principal: actor, target: principalId, meta: { validAfter } });
|
|
48
|
+
sec.options.log.info(`[revocation] tokens revoked for ${principalId} (validAfter=${validAfter}) by ${actor ?? 'system'}`);
|
|
49
|
+
return validAfter;
|
|
50
|
+
}
|