shraga 0.1.113 → 0.1.114
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 +8 -1
- package/defaults/scripts/agent-once.ts +2 -0
- package/defaults/security/policy.example.json +27 -0
- package/defaults/skills/mcp-server.md +1 -7
- package/defaults/skills/platform.md +2 -2
- package/defaults/system-prompt.md +1 -1
- package/dist/client/assets/index-CFxKc8dH.css +10 -0
- package/dist/client/assets/index-DwmlZAEF.js +2009 -0
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/client/components/ConfigPanel.tsx +22 -0
- package/src/client/components/Sidebar.tsx +16 -1
- package/src/client/components/owner/ApiKeysTab.tsx +87 -0
- package/src/client/components/owner/AuditTab.tsx +110 -0
- package/src/client/components/owner/BindingsTab.tsx +110 -0
- package/src/client/components/owner/BlocklistTab.tsx +96 -0
- package/src/client/components/owner/OwnerConsole.tsx +87 -0
- package/src/client/components/owner/PrincipalsTab.tsx +55 -0
- package/src/client/components/owner/RolesTab.tsx +132 -0
- package/src/client/components/owner/shared.tsx +84 -0
- package/src/client/hooks/useOwner.ts +20 -0
- package/src/client/lib/api.ts +2 -2
- package/src/index.ts +10 -2
- package/src/server/agent-config.ts +34 -0
- package/src/server/api-key-routes.ts +5 -3
- package/src/server/boot.ts +4 -1
- package/src/server/claude.ts +4 -23
- package/src/server/data-sync.ts +1 -0
- package/src/server/engine/claude-code.ts +34 -3
- package/src/server/security/audit.ts +12 -5
- package/src/server/security/enforce.ts +43 -6
- package/src/server/security/guard.ts +8 -0
- package/src/server/security/owner-only.ts +21 -2
- package/src/server/security/owner-routes.ts +246 -4
- package/src/server/security/policy.ts +4 -1
- package/src/server/security/runtime.ts +53 -1
- package/src/server/sessions.ts +3 -0
- package/src/server/shraga-config.ts +13 -0
- package/dist/client/assets/index-DIDtPQb-.css +0 -10
- package/dist/client/assets/index-DJ0AgGIu.js +0 -1969
|
@@ -204,23 +204,58 @@ export const PROTECTED_DATA_WRITE: readonly string[] = [
|
|
|
204
204
|
'oauth-clients.json', 'mcps/', '.internal-token', '.mcp-oauth-secret', '.local-auth-secret', 'users.json',
|
|
205
205
|
// data-sync's own repo: .git/config (core.fsmonitor, hooks) runs code on its next git call; .gitignore untracks state.
|
|
206
206
|
'.git/', '.gitignore',
|
|
207
|
+
// Untrusted inbound content held for operator review: the agent must not rewrite it (launder the evidence, or edit
|
|
208
|
+
// it into something an operator then approves). Reads are denied too — see PROTECTED_DATA_READ.
|
|
209
|
+
'quarantine/',
|
|
207
210
|
];
|
|
211
|
+
/** DATA_DIR-relative paths agent file tools may not READ either (same syntax as PROTECTED_DATA_WRITE).
|
|
212
|
+
* `quarantine/` holds attacker-controlled inbound text kept for operator review. Not writing it stops the agent
|
|
213
|
+
* laundering the evidence; not READING it is the point of the quarantine — ingesting that text into a turn IS the
|
|
214
|
+
* prompt-injection vector. What this buys, precisely:
|
|
215
|
+
* - RESTRICTED profiles (tools without `*`): a guarantee. No Bash/Grep at all, and Read/Glob/Grep are path-checked
|
|
216
|
+
* literally and by realpath, so quarantined text cannot enter the turn through an agent tool.
|
|
217
|
+
* - FULL profiles (owner/operator): best-effort, exactly as for secret paths above. Bash is auto-approved before
|
|
218
|
+
* canUseTool, so `cat <data>/quarantine/x.json` is not stopped by this (nor by any deny list here); the file-tool
|
|
219
|
+
* deny stops accidental ingestion, not a determined turn. Plan step 8 is the OS-level fix.
|
|
220
|
+
* - Server-side code is unaffected: the lane that writes quarantine and the owner route that reads it use fs/HTTP,
|
|
221
|
+
* never agent tools. This gate only sees agent tool calls.
|
|
222
|
+
* Enforcement-only: TurnGuard is the sole caller, so with SECURITY_ENFORCE unset nothing here changes. */
|
|
223
|
+
export const PROTECTED_DATA_READ: readonly string[] = ['quarantine/'];
|
|
224
|
+
export const PROTECTED_DATA_READ_MESSAGE = 'Quarantined inbound content is held for operator review and cannot be read by agent tools.';
|
|
208
225
|
const WRITE_TOOLS = new Set(['Write', 'Edit', 'MultiEdit', 'NotebookEdit']);
|
|
209
226
|
export const PROTECTED_DATA_MESSAGE = 'Audit logs, conversations, sessions, security policy, keys and MCP config are server-owned and cannot be modified by agent tools.';
|
|
210
227
|
|
|
211
|
-
/**
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
228
|
+
/** Do any of these path forms land on a DATA_DIR entry in `entries`? Absolute forms only (a relative literal would be
|
|
229
|
+
* resolved against the process cwd by path.relative, which is not the form we mean); data dir matched in absolute and
|
|
230
|
+
* realpath form. */
|
|
231
|
+
function underDataEntry(forms: string[], entries: readonly string[], cwd: string, dataDir: string): boolean {
|
|
215
232
|
const roots = [...new Set(pathForms(dataDir, cwd).slice(1))];
|
|
216
|
-
return
|
|
233
|
+
return forms.filter(f => path.isAbsolute(f)).some(f => roots.some(root => {
|
|
217
234
|
const rel = path.relative(root, f);
|
|
218
235
|
if (!rel || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return false;
|
|
219
236
|
const r = rel.split(path.sep).join('/').toLowerCase(); // case-insensitive filesystems (macOS) alias Audit/ to audit/
|
|
220
|
-
return
|
|
237
|
+
return entries.some(e => (e.endsWith('/') ? r === e.slice(0, -1) || r.startsWith(e) : r === e));
|
|
221
238
|
}));
|
|
222
239
|
}
|
|
223
240
|
|
|
241
|
+
/** Would this file tool write a protected data path? Target and data dir both matched in absolute and realpath form. */
|
|
242
|
+
export function writesProtectedData(tool: string, input: Record<string, unknown>, cwd: string = process.cwd(), dataDir: string = DATA_DIR): boolean {
|
|
243
|
+
const target = WRITE_TOOLS.has(tool) ? str(input.file_path) ?? str(input.notebook_path) : undefined;
|
|
244
|
+
if (!target) return false;
|
|
245
|
+
return underDataEntry(pathForms(target, cwd), PROTECTED_DATA_WRITE, cwd, dataDir);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Would this call READ or LIST a protected data path (PROTECTED_DATA_READ)? Same path forms as touchesSecretPath, so
|
|
249
|
+
* Read/Edit targets, a Glob/Grep search root and a Glob pattern are all covered. Bash is not — best-effort, see above. */
|
|
250
|
+
export function readsProtectedData(tool: string, input: Record<string, unknown>, cwd: string = process.cwd(), dataDir: string = DATA_DIR): boolean {
|
|
251
|
+
if (tool === 'Bash') return false;
|
|
252
|
+
const hit = (forms: string[]) => underDataEntry(forms, PROTECTED_DATA_READ, cwd, dataDir);
|
|
253
|
+
const root = str(input.path);
|
|
254
|
+
const pattern = tool === 'Glob' ? str(input.pattern) : tool === 'Grep' ? str(input.glob) : undefined;
|
|
255
|
+
if (pattern && hit(globForms(pattern, root, cwd))) return true;
|
|
256
|
+
return [str(input.file_path), str(input.notebook_path), root].some(p => p !== undefined && hit(pathForms(p, cwd)));
|
|
257
|
+
}
|
|
258
|
+
|
|
224
259
|
/** Restricted profiles: does this Glob search outside the workspace root (`cwd`)? Its base is realpath-checked; any
|
|
225
260
|
* `..` segment in the pattern counts as outside. */
|
|
226
261
|
export function globOutsideWorkspace(input: Record<string, unknown>, cwd: string = process.cwd()): boolean {
|
|
@@ -274,6 +309,7 @@ export class TurnGuard {
|
|
|
274
309
|
}
|
|
275
310
|
const restricted = !eff.profile.tools.includes('*');
|
|
276
311
|
const reason = writesProtectedData(tool, input, cwd) ? 'protected-path'
|
|
312
|
+
: readsProtectedData(tool, input, cwd) ? 'quarantined-path'
|
|
277
313
|
: touchesSecretPath(tool, input, cwd, restricted) ? 'secret-path'
|
|
278
314
|
: !profileAllowsTool(eff.profile, tool) ? 'profile'
|
|
279
315
|
: restricted && tool === 'Glob' && globOutsideWorkspace(input, cwd) ? 'outside-workspace' : undefined;
|
|
@@ -287,6 +323,7 @@ export class TurnGuard {
|
|
|
287
323
|
if (!reason) return { allow: true };
|
|
288
324
|
log.log(`[security] Denied ${tool} for ${principal.id} (role=${eff.role} profile=${eff.profileName} ${reason}) session=${sessionId ?? 'new'}`);
|
|
289
325
|
if (reason === 'protected-path') return { allow: false, message: PROTECTED_DATA_MESSAGE };
|
|
326
|
+
if (reason === 'quarantined-path') return { allow: false, message: PROTECTED_DATA_READ_MESSAGE };
|
|
290
327
|
if (reason === 'secret-path') return { allow: false, message: 'Credential and secret files are not accessible.' };
|
|
291
328
|
if (reason === 'outside-workspace') return { allow: false, message: `Glob is limited to the workspace for role "${eff.role}".` };
|
|
292
329
|
const hint = allowsEscalate(eff.profile) ? ` Use the ${ESCALATE_TOOL} tool to hand this request to an owner.` : '';
|
|
@@ -160,6 +160,14 @@ export class Guard {
|
|
|
160
160
|
return [...this.blocks.map].filter(([, b]) => b.until > now).map(([key, b]) => ({ key, ...b }));
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/** Clear an auto-block (Owner Console) and its hit window; persists. False when no such block exists. */
|
|
164
|
+
public unblock(key: string): boolean {
|
|
165
|
+
if (!this.blocks.map.delete(key)) return false;
|
|
166
|
+
this.hits.map.delete(key);
|
|
167
|
+
this.save();
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
163
171
|
/** blocklist → auto-blocks → rate. Consumes one token from each applicable bucket only when all pass. */
|
|
164
172
|
public check(input: GuardInput): GuardVerdict {
|
|
165
173
|
const denial = this.evaluate(input);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Owner gate for admin actions. `isOwner` is set by requireAuth from the OWNERS env (owners.ts), so this
|
|
2
2
|
// must run AFTER requireAuth. Real enforcement (not shadow): only for system-scope mutations.
|
|
3
3
|
import type { Request, Response, RequestHandler } from 'express';
|
|
4
|
+
import { security } from './runtime.ts';
|
|
4
5
|
|
|
5
6
|
/** True if the caller is an owner; otherwise responds 403 with `message` and returns false. */
|
|
6
7
|
export function ownerOnly(req: Request, res: Response, message = 'Only an owner can do this'): boolean {
|
|
@@ -9,7 +10,25 @@ export function ownerOnly(req: Request, res: Response, message = 'Only an owner
|
|
|
9
10
|
return false;
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
/**
|
|
13
|
+
/** 403 + `auth.deny` audit unless the caller's principal kind is one of `kinds`. */
|
|
14
|
+
function kindGate(kinds: string[], error: string): RequestHandler {
|
|
15
|
+
return (req, res, next) => {
|
|
16
|
+
const kind = (req as any).user?.principal?.kind ?? 'unknown';
|
|
17
|
+
if (kinds.includes(kind)) return next();
|
|
18
|
+
console.warn(`[owner-only] ${req.method} ${req.path} refused: ${kind} credential`);
|
|
19
|
+
security()?.authDeny(`http:${req.path}`, `non-interactive:${kind}`, req.ip);
|
|
20
|
+
res.status(403).json({ error });
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Middleware form of ownerOnly, for policy/config/skills/MCP routes. The agent subprocess carries an owner-signed
|
|
25
|
+
* internal token (INTERNAL_API_TOKEN), so an internal principal is never owner HERE — otherwise a prompt-injected turn
|
|
26
|
+
* could rewrite its own policy. Owner = interactive login or an uncapped owner API key (CLI terminal). Inline
|
|
27
|
+
* `ownerOnly` callers (self-upgrade, schedules, modules) keep accepting the internal token. */
|
|
13
28
|
export function requireOwner(message?: string): RequestHandler {
|
|
14
|
-
|
|
29
|
+
const notInternal = kindGate(['user', 'apikey'], 'Owner access requires an interactive login or an owner API key — not the agent\'s internal token');
|
|
30
|
+
return (req, res, next) => { if (ownerOnly(req, res, message)) notInternal(req, res, next); };
|
|
15
31
|
}
|
|
32
|
+
|
|
33
|
+
/** Security-console writes: an interactive login only (no API key, no internal token) — same rule as minting a key. */
|
|
34
|
+
export const requireInteractive = (): RequestHandler => kindGate(['user'], 'This change requires an interactive login');
|
|
@@ -1,25 +1,155 @@
|
|
|
1
1
|
// Owner-only admin API (`/api/owner/*`). Each route runs requireAuth + requireOwner itself — never router-wide, so
|
|
2
|
-
// mounting this router can't gate unrelated requests.
|
|
2
|
+
// mounting this router can't gate unrelated requests. Consumer: the Owner Console (client/components/owner/).
|
|
3
|
+
// Policy writes: validated by Policy's own validatePolicy, saved via policy.save() (provenance holds), audited as
|
|
4
|
+
// `policy.change` with the changed paths only (never values). Every write on a PASSIVE standby → 409.
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
3
6
|
import { Router, type Request, type Response } from 'express';
|
|
4
7
|
import { requireAuth, type AuthUser } from '../auth.ts';
|
|
5
8
|
import { apiKeyRouter } from '../api-key-routes.ts';
|
|
6
|
-
import {
|
|
9
|
+
import { apiKeyPrincipal } from '../api-keys.ts';
|
|
10
|
+
import { getOwners } from '../owners.ts';
|
|
11
|
+
import { canonical, type AuditEventType } from './audit.ts';
|
|
12
|
+
import { matches, validatePolicy, type BindingMatch, type PolicyFile } from './policy.ts';
|
|
13
|
+
import { anonymous, fromAuthUser, fromEmailSender, fromInternal, fromSlack, type Principal } from './principal.ts';
|
|
14
|
+
import { requireInteractive, requireOwner } from './owner-only.ts';
|
|
7
15
|
import { revocablePrincipalId, revokeTokens } from './revocation.ts';
|
|
16
|
+
import { resolvePrincipal, security, type SecurityRuntime } from './runtime.ts';
|
|
8
17
|
|
|
18
|
+
/** Reads: owner via interactive login or uncapped owner API key (never the agent's internal token). */
|
|
9
19
|
const gate = [requireAuth, requireOwner()];
|
|
20
|
+
/** Writes: interactive login only. */
|
|
21
|
+
const writeGate = [...gate, requireInteractive()];
|
|
10
22
|
/** The raw INTERNAL_API_TOKEN's principal (auth.ts) — no issued-at, so tokensValidAfter can never reject it. */
|
|
11
23
|
const LEGACY_INTERNAL_ID = 'internal:agent-internal';
|
|
12
24
|
const userOf = (req: Request) => (req as any).user as AuthUser;
|
|
25
|
+
/** OWNERS as the principals a block could hit: each owner's login and verified email channel. */
|
|
26
|
+
const ownerPrincipals = () => getOwners().flatMap(e => [fromAuthUser({ uid: e, email: e }), fromEmailSender(e, true)]);
|
|
13
27
|
|
|
14
28
|
const fail = (res: Response, e: any, status = 400) => {
|
|
15
29
|
console.warn(`[owner-routes] ${e?.message ?? e}`);
|
|
16
30
|
res.status(status).json({ error: e?.message ?? String(e) });
|
|
17
31
|
};
|
|
18
32
|
|
|
33
|
+
const isObj = (v: unknown): v is Record<string, any> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
34
|
+
const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
|
|
35
|
+
|
|
36
|
+
/** Opaque version of a policy document. A PUT carrying a stale version is refused, so a Console tab opened earlier
|
|
37
|
+
* can't silently undo a revoke/block made meanwhile. */
|
|
38
|
+
export const policyVersion = (p: PolicyFile) => createHash('sha256').update(canonical(p)).digest('hex').slice(0, 16);
|
|
39
|
+
|
|
40
|
+
/** Paths that differ, two levels deep (`profiles.standard`, `bindings.2`, `default`) — names only, never values. */
|
|
41
|
+
export function policyDiff(a: object, b: object, max = 30): string[] {
|
|
42
|
+
const keys = (x: object, y: object) => [...new Set([...Object.keys(x), ...Object.keys(y)])];
|
|
43
|
+
const out: string[] = [];
|
|
44
|
+
for (const k of keys(a, b)) {
|
|
45
|
+
const x = (a as any)[k], y = (b as any)[k];
|
|
46
|
+
if (canonical(x) === canonical(y)) continue;
|
|
47
|
+
if (x && y && typeof x === 'object' && typeof y === 'object') {
|
|
48
|
+
for (const s of keys(x, y)) if (canonical(x[s]) !== canonical(y[s])) out.push(`${k}.${s}`);
|
|
49
|
+
} else out.push(k);
|
|
50
|
+
}
|
|
51
|
+
return out.length > max ? [...out.slice(0, max), `+${out.length - max} more`] : out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The runtime, or an error response: 503 when uninitialized; 409 for a write on a PASSIVE standby. */
|
|
55
|
+
function runtimeOr(res: Response, write = false): SecurityRuntime | undefined {
|
|
56
|
+
const sec = security();
|
|
57
|
+
if (!sec) return void res.status(503).json({ error: 'Security runtime not initialized' });
|
|
58
|
+
if (write) {
|
|
59
|
+
let active = false;
|
|
60
|
+
try { active = sec.options.isActive(); } catch (e: any) { console.warn(`[owner-routes] isActive threw — treating as PASSIVE: ${e?.message ?? e}`); }
|
|
61
|
+
if (!active) return void res.status(409).json({ error: 'Read-only on a PASSIVE standby — use the active instance' });
|
|
62
|
+
}
|
|
63
|
+
return sec;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Incremental edits (blocklist) start from the current document — refused in fail-closed mode, where saving would
|
|
67
|
+
* replace the broken-but-fixable file with the owners-only fallback. A whole-document PUT is the fix path. */
|
|
68
|
+
function requireValidPolicy(res: Response, sec: SecurityRuntime): boolean {
|
|
69
|
+
if (sec.policy.valid) return true;
|
|
70
|
+
res.status(409).json({ error: 'Policy is invalid (fail-closed) — fix it with a full policy save first' });
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** validatePolicy → policy.save() → `policy.change` audit. Responds with the error itself; true on success. */
|
|
75
|
+
function commit(req: Request, res: Response, sec: SecurityRuntime, next: PolicyFile, op: string): boolean {
|
|
76
|
+
const errors = validatePolicy(next);
|
|
77
|
+
if (errors.length) {
|
|
78
|
+
console.warn(`[owner-routes] ${op} refused: ${errors.join('; ')}`);
|
|
79
|
+
res.status(400).json({ error: `Invalid policy: ${errors.join('; ')}`, errors });
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
const prev = sec.policy.current;
|
|
83
|
+
try { sec.policy.save(next); } catch (e: any) { fail(res, e, 500); return false; }
|
|
84
|
+
sec.record({ type: 'policy.change', principal: userOf(req).principal.id, target: op, meta: { changed: policyDiff(prev, sec.policy.current) } });
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const KINDS = ['user', 'email', 'slack', 'apikey', 'internal', 'anonymous'];
|
|
89
|
+
|
|
90
|
+
/** A principal built by the SAME builders the channels use, from `{ kind, id?, email?, verified?, lane?, role?, attrs? }`.
|
|
91
|
+
* `role` (apikey only) = the key's role cap; for an apikey, `email` is the creator. */
|
|
92
|
+
export function principalFromDescriptor(d: any): Principal {
|
|
93
|
+
const kind = d?.kind, email = str(d?.email), id = str(d?.id);
|
|
94
|
+
const attrs: Record<string, unknown> = isObj(d?.attrs) ? { ...d.attrs } : {};
|
|
95
|
+
const lane = str(d?.lane) ?? str(attrs.lane), who = id ?? email;
|
|
96
|
+
if (!KINDS.includes(kind)) throw new Error(`kind must be one of ${KINDS.join(', ')}`);
|
|
97
|
+
if (kind !== 'anonymous' && !who) throw new Error('id or email is required');
|
|
98
|
+
let p: Principal;
|
|
99
|
+
switch (kind) {
|
|
100
|
+
case 'user': p = fromAuthUser({ uid: who!, email }); break;
|
|
101
|
+
case 'email': p = fromEmailSender(who!, d?.verified === true); break;
|
|
102
|
+
case 'slack': p = fromSlack(who!, { email }); break;
|
|
103
|
+
case 'apikey': p = apiKeyPrincipal({ id: id ?? 'console-test', uid: str(attrs.uid) ?? who!, email: email ?? '', role: str(d?.role) ?? str(attrs.role) }); break;
|
|
104
|
+
case 'internal': p = fromInternal({ uid: who!, email, lane }); break;
|
|
105
|
+
default: p = anonymous();
|
|
106
|
+
}
|
|
107
|
+
if (typeof d?.verified === 'boolean') p.verified = d.verified;
|
|
108
|
+
Object.assign(p.attrs, attrs, lane ? { lane } : {});
|
|
109
|
+
return p;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const DENY_TYPES = new Set<string>(['auth.deny', 'tool.deny', 'guard.block', 'guard.limit']);
|
|
113
|
+
/** Events whose `role` is the ACTING principal's role. Others carry a different subject's role (key.create: the key's cap). */
|
|
114
|
+
const OWN_ROLE_TYPES = new Set<string>(['role.resolve', 'turn.start', 'turn.end', 'tool.allow', 'tool.deny', 'escalate']);
|
|
115
|
+
const PRINCIPAL_SCAN_CAP = 50_000;
|
|
116
|
+
export interface PrincipalRow { id: string; kind: string; lastRole?: string; lastSeen: string; turns: number; denies: number }
|
|
117
|
+
|
|
118
|
+
/** Principals in the audit since `sinceMs`, most recently seen first. Denies include shadow (would-deny) verdicts. */
|
|
119
|
+
export function recentPrincipals(sec: SecurityRuntime, sinceMs: number, limit: number): { principals: PrincipalRow[]; truncated: boolean } {
|
|
120
|
+
const rows = new Map<string, PrincipalRow>();
|
|
121
|
+
let cursor: string | undefined, scanned = 0;
|
|
122
|
+
do {
|
|
123
|
+
const page = sec.audit.query({ from: sinceMs, limit: 1000, cursor });
|
|
124
|
+
for (const r of page.items) {
|
|
125
|
+
scanned++;
|
|
126
|
+
if (!r.principal) continue;
|
|
127
|
+
let row = rows.get(r.principal);
|
|
128
|
+
if (!row) rows.set(r.principal, row = { id: r.principal, kind: r.principal.split(':')[0], lastSeen: r.ts, turns: 0, denies: 0 });
|
|
129
|
+
if (!row.lastRole && r.role && OWN_ROLE_TYPES.has(r.type)) row.lastRole = r.role;
|
|
130
|
+
if (r.type === 'turn.start') row.turns++;
|
|
131
|
+
else if (DENY_TYPES.has(r.type)) row.denies++;
|
|
132
|
+
}
|
|
133
|
+
cursor = page.nextCursor;
|
|
134
|
+
} while (cursor && scanned < PRINCIPAL_SCAN_CAP);
|
|
135
|
+
return { principals: [...rows.values()].slice(0, limit), truncated: !!cursor };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const clampInt = (v: unknown, def: number, min: number, max: number) => Math.max(min, Math.min(max, Math.floor(Number(v)) || def));
|
|
139
|
+
const when = (v: unknown): Date | undefined => {
|
|
140
|
+
const s = str(v);
|
|
141
|
+
if (!s) return undefined;
|
|
142
|
+
const d = new Date(/^\d+$/.test(s) ? Number(s) : s);
|
|
143
|
+
if (Number.isNaN(d.getTime())) throw new Error(`invalid date "${s}"`);
|
|
144
|
+
return d;
|
|
145
|
+
};
|
|
146
|
+
|
|
19
147
|
export const ownerRouter = Router();
|
|
20
148
|
|
|
149
|
+
// ── Tokens + API keys ─────────────────────────────────────────────────────────
|
|
150
|
+
|
|
21
151
|
/** Invalidate every token issued to a principal until now. Body: { principalId: "user:<email|uid>" | "internal:<uid>" }. */
|
|
22
|
-
ownerRouter.post('/api/owner/tokens/revoke', ...
|
|
152
|
+
ownerRouter.post('/api/owner/tokens/revoke', ...writeGate, (req, res) => {
|
|
23
153
|
const { principalId } = (req.body ?? {}) as { principalId?: unknown };
|
|
24
154
|
const id = typeof principalId === 'string' ? revocablePrincipalId(principalId) : null;
|
|
25
155
|
if (!id) {
|
|
@@ -40,4 +170,116 @@ ownerRouter.post('/api/owner/tokens/revoke', ...gate, (req, res) => {
|
|
|
40
170
|
} catch (e: any) { fail(res, e, 409); }
|
|
41
171
|
});
|
|
42
172
|
|
|
43
|
-
ownerRouter.use(apiKeyRouter({ base: '/api/owner/api-keys', gate, asOwner: true }));
|
|
173
|
+
ownerRouter.use(apiKeyRouter({ base: '/api/owner/api-keys', gate, writeGate: [requireInteractive()], asOwner: true }));
|
|
174
|
+
|
|
175
|
+
// ── Policy ────────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
ownerRouter.get('/api/owner/policy', ...gate, (_req, res) => {
|
|
178
|
+
const sec = runtimeOr(res);
|
|
179
|
+
if (!sec) return;
|
|
180
|
+
const policy = sec.policy.current;
|
|
181
|
+
// ownerIds: a UI hint (hide Block on owner rows); the block route enforces it.
|
|
182
|
+
res.json({ policy, version: policyVersion(policy), valid: sec.policy.valid, ownerIds: ownerPrincipals().map(p => p.id) });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
/** Body: { policy, version } — the whole document; `version` (from GET, required) guards against overwriting a newer
|
|
186
|
+
* save. `tokensValidAfter` and `blocklist` always come from the CURRENT policy — they change only via their own routes,
|
|
187
|
+
* so a PUT can't un-revoke or un-block. */
|
|
188
|
+
ownerRouter.put('/api/owner/policy', ...writeGate, (req, res) => {
|
|
189
|
+
const sec = runtimeOr(res, true);
|
|
190
|
+
if (!sec) return;
|
|
191
|
+
const { policy, version } = (req.body ?? {}) as { policy?: unknown; version?: unknown };
|
|
192
|
+
if (!isObj(policy)) return void res.status(400).json({ error: 'Body must be { policy, version }' });
|
|
193
|
+
const current = sec.policy.current;
|
|
194
|
+
if (version !== policyVersion(current)) {
|
|
195
|
+
return void res.status(409).json({ error: version === undefined ? 'version is required — GET the policy first' : 'The policy changed since you loaded it — reload and re-apply your edit' });
|
|
196
|
+
}
|
|
197
|
+
const next = { ...policy, tokensValidAfter: current.tokensValidAfter, blocklist: current.blocklist } as PolicyFile;
|
|
198
|
+
if (commit(req, res, sec, next, 'policy')) res.json({ ok: true, version: policyVersion(sec.policy.current), valid: sec.policy.valid });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
/** Resolve a descriptor exactly as a turn would (resolvePrincipal — decide()'s path), without auditing `role.resolve`. */
|
|
202
|
+
ownerRouter.post('/api/owner/policy/test', ...gate, (req, res) => {
|
|
203
|
+
const sec = runtimeOr(res);
|
|
204
|
+
if (!sec) return;
|
|
205
|
+
let p: Principal;
|
|
206
|
+
try { p = principalFromDescriptor(req.body); } catch (e: any) { return fail(res, e); }
|
|
207
|
+
const r = resolvePrincipal(sec.policy, p);
|
|
208
|
+
res.json({ principal: p.id, role: r.role, rank: r.rank, profile: r.profileName, capabilities: r.profile, blocked: sec.policy.blocked(p) ?? null });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// ── Principals ────────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/** Query: days (1–90, default 7), limit (1–500, default 200). */
|
|
214
|
+
ownerRouter.get('/api/owner/principals', ...gate, (req, res) => {
|
|
215
|
+
const sec = runtimeOr(res);
|
|
216
|
+
if (!sec) return;
|
|
217
|
+
const days = clampInt(req.query.days, 7, 1, 90), limit = clampInt(req.query.limit, 200, 1, 500);
|
|
218
|
+
try { res.json({ days, ...recentPrincipals(sec, Date.now() - days * 86_400_000, limit) }); } catch (e: any) { fail(res, e, 500); }
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ── Blocklist ─────────────────────────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
/** Manual entries (policy blocklist, `until` epoch SECONDS) + guard auto-blocks (`until` epoch MS). */
|
|
224
|
+
ownerRouter.get('/api/owner/blocks', ...gate, (_req, res) => {
|
|
225
|
+
const sec = runtimeOr(res);
|
|
226
|
+
if (!sec) return;
|
|
227
|
+
res.json({ manual: sec.policy.current.blocklist.map((b, index) => ({ index, ...b })), auto: sec.guard.list() });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
/** Body: { match, until?: epoch seconds | null, reason? } — a manual block. */
|
|
231
|
+
ownerRouter.post('/api/owner/blocks', ...writeGate, (req, res) => {
|
|
232
|
+
const sec = runtimeOr(res, true);
|
|
233
|
+
if (!sec || !requireValidPolicy(res, sec)) return;
|
|
234
|
+
const { match, until = null, reason } = (req.body ?? {}) as { match?: unknown; until?: unknown; reason?: unknown };
|
|
235
|
+
const owner = isObj(match) && ownerPrincipals().find(p => matches(match as BindingMatch, p));
|
|
236
|
+
if (owner) return void res.status(400).json({ error: `This block would match the owner ${owner.email ?? owner.id} — owners can't be blocked (change OWNERS instead)` });
|
|
237
|
+
if (until !== null && (typeof until !== 'number' || until >= 1e11)) return void res.status(400).json({ error: 'until must be epoch SECONDS or null' });
|
|
238
|
+
if (typeof until === 'number' && until <= Date.now() / 1000) return void res.status(400).json({ error: 'until is in the past' });
|
|
239
|
+
const next = sec.policy.current;
|
|
240
|
+
next.blocklist = [...next.blocklist, { match: match as BindingMatch, until, ...(str(reason) ? { reason: str(reason) } : {}) }];
|
|
241
|
+
if (commit(req, res, sec, next, 'blocklist.add')) res.json({ ok: true, index: next.blocklist.length - 1 });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
/** Body: { source: "manual", index, match } (match must still equal the entry at index) | { source: "auto", key }. */
|
|
245
|
+
ownerRouter.delete('/api/owner/blocks', ...writeGate, (req, res) => {
|
|
246
|
+
const sec = runtimeOr(res, true);
|
|
247
|
+
if (!sec) return;
|
|
248
|
+
const b = (req.body ?? {}) as { source?: unknown; index?: unknown; match?: unknown; key?: unknown };
|
|
249
|
+
if (b.source === 'auto') {
|
|
250
|
+
if (typeof b.key !== 'string' || !sec.guard.unblock(b.key)) return void res.status(404).json({ error: 'No such auto-block' });
|
|
251
|
+
sec.record({ type: 'policy.change', principal: userOf(req).principal.id, target: `auto-block:${b.key}`, meta: { op: 'auto-block.clear' } });
|
|
252
|
+
return void res.json({ ok: true });
|
|
253
|
+
}
|
|
254
|
+
if (b.source !== 'manual') return void res.status(400).json({ error: 'source must be "manual" or "auto"' });
|
|
255
|
+
if (!requireValidPolicy(res, sec)) return;
|
|
256
|
+
const next = sec.policy.current;
|
|
257
|
+
const entry = typeof b.index === 'number' && Number.isInteger(b.index) ? next.blocklist[b.index] : undefined;
|
|
258
|
+
if (!entry) return void res.status(404).json({ error: 'No such manual block' });
|
|
259
|
+
if (canonical(entry.match) !== canonical(b.match)) return void res.status(409).json({ error: 'The blocklist changed since you loaded it — reload' });
|
|
260
|
+
next.blocklist = next.blocklist.filter((_, i) => i !== b.index);
|
|
261
|
+
if (commit(req, res, sec, next, 'blocklist.remove')) res.json({ ok: true });
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// The console deliberately exposes no conversation-delete route: the audit log is append-only, and removing a
|
|
265
|
+
// conversation is a manual, on-box operation. The `session.delete` audit type stays for those out-of-band removals.
|
|
266
|
+
|
|
267
|
+
// ── Audit ─────────────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
/** Query: from, to (ISO or epoch ms), type (comma list), principal, limit (1–500, default 100), cursor. Newest first. */
|
|
270
|
+
ownerRouter.get('/api/owner/audit', ...gate, (req, res) => {
|
|
271
|
+
const sec = runtimeOr(res);
|
|
272
|
+
if (!sec) return;
|
|
273
|
+
try {
|
|
274
|
+
const type = str(req.query.type)?.split(',').map(s => s.trim()).filter(Boolean) as AuditEventType[] | undefined;
|
|
275
|
+
res.json(sec.audit.query({
|
|
276
|
+
from: when(req.query.from), to: when(req.query.to), type, principal: str(req.query.principal),
|
|
277
|
+
limit: clampInt(req.query.limit, 100, 1, 500), cursor: str(req.query.cursor),
|
|
278
|
+
}));
|
|
279
|
+
} catch (e: any) { fail(res, e, /cursor|date/.test(e?.message ?? '') ? 400 : 500); }
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
ownerRouter.get('/api/owner/audit/verify', ...gate, (_req, res) => {
|
|
283
|
+
const sec = runtimeOr(res);
|
|
284
|
+
if (sec) res.json(sec.audit.verify());
|
|
285
|
+
});
|
|
@@ -39,6 +39,9 @@ export type TamperReason = 'hash-mismatch' | 'deleted';
|
|
|
39
39
|
export const OWNER_ROLE = 'owner';
|
|
40
40
|
/** Role of no-human system lanes (see resolve). */
|
|
41
41
|
export const OPERATOR_ROLE = 'operator';
|
|
42
|
+
/** Trust watermark: at or above this rank a principal is a known human of the deployment. Below it
|
|
43
|
+
* (guest, anonymous) the sender is untrusted — see `mayReplyTo` (runtime.ts). */
|
|
44
|
+
export const MEMBER_ROLE = 'member';
|
|
42
45
|
const NONE_PROFILE: Profile = { tools: [], mcps: [], env: [], outbound: false, readScope: 'none', rate: '0' };
|
|
43
46
|
const FULL_PROFILE: Profile = { tools: ['*'], mcps: ['*'], env: ['*'], outbound: true, readScope: 'all', rate: '600/h' };
|
|
44
47
|
|
|
@@ -124,7 +127,7 @@ export function validatePolicy(p: any): string[] {
|
|
|
124
127
|
return errs;
|
|
125
128
|
}
|
|
126
129
|
|
|
127
|
-
function matches(m: BindingMatch, p: Principal): boolean {
|
|
130
|
+
export function matches(m: BindingMatch, p: Principal): boolean {
|
|
128
131
|
if (m.kind !== undefined && m.kind !== p.kind) return false;
|
|
129
132
|
if (m.id !== undefined && m.id !== p.id) return false;
|
|
130
133
|
if (m.verified !== undefined && m.verified !== p.verified) return false;
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
// Writes are gated on `isActive` (a PASSIVE standby shares DATA_DIR and must not append to data/audit, nor let the
|
|
8
8
|
// Policy migrate/save/mark or judge tamper; `activate()` re-loads the policy on promotion), and noisy events are
|
|
9
9
|
// deduped per key per window, so a busy client isn't one line per request.
|
|
10
|
-
import { OWNER_ROLE, Policy, type PolicyOptions, type Resolved, type TamperReason } from './policy.ts';
|
|
10
|
+
import { MEMBER_ROLE, OWNER_ROLE, Policy, type PolicyOptions, type Resolved, type TamperReason } from './policy.ts';
|
|
11
|
+
import { getAgentConfig } from '../agent-config.ts';
|
|
11
12
|
import { Audit, type AuditEvent, type AuditOptions } from './audit.ts';
|
|
12
13
|
import { Guard, type GuardOptions, type TurnAdmission } from './guard.ts';
|
|
13
14
|
import { fromAuthUser, type Principal } from './principal.ts';
|
|
@@ -165,6 +166,57 @@ export function admitTurn(principal: Principal, ctx?: { ip?: string; channel?: s
|
|
|
165
166
|
catch (e: any) { console.error(`[security] admitTurn threw — admitting: ${e?.message ?? e}`); return { ok: true, release: () => {} }; }
|
|
166
167
|
}
|
|
167
168
|
|
|
169
|
+
/**
|
|
170
|
+
* May the agent send an AUTOMATIC outbound reply to this inbound principal? For channels and add-ons
|
|
171
|
+
* (mail, chat) to call before handing the model's text back to the sender.
|
|
172
|
+
*
|
|
173
|
+
* `false` when `allowUntrustedReplies` is off (the default) and the principal is either UNVERIFIED or
|
|
174
|
+
* resolves BELOW `member` rank — guest, anonymous, a spoofable sender. Suppressing the reply does not drop
|
|
175
|
+
* the turn: it still runs, and `escalate` still reaches owners. A verified member/operator/owner is always
|
|
176
|
+
* replyable, and this never gates a human operator's own turns.
|
|
177
|
+
*
|
|
178
|
+
* WHY `verified` and not rank alone: a binding on `domain`/`emailIn` matches the CLAIMED `From:`, which
|
|
179
|
+
* nothing has authenticated, so `{ match: { domain: 'x' }, role: 'member' }` would otherwise let a spoofer
|
|
180
|
+
* promote himself to member rank and earn an automatic reply. Kinds whose identity is proven by construction
|
|
181
|
+
* set `verified: true` in their builder — `fromAuthUser` (user), `fromApiKey` (apikey), `fromInternal`
|
|
182
|
+
* (internal, incl. the no-human lanes) and `fromSlack` (slack) — so requiring `verified` never silences a
|
|
183
|
+
* logged-in user, an API key, an internal lane or a signature-verified Slack sender. Only `fromEmailSender`
|
|
184
|
+
* with an unaligned DKIM/DMARC result, and `anonymous()`, carry `verified: false`.
|
|
185
|
+
*
|
|
186
|
+
* Independent of `SECURITY_ENFORCE` — it gates outbound replies, not tools, so it applies in shadow mode too.
|
|
187
|
+
*
|
|
188
|
+
* FAILS CLOSED: no runtime, an invalid/fail-closed policy (no `member` role), or a throw ⇒ `false`.
|
|
189
|
+
*
|
|
190
|
+
* AUDIT DEDUPE — pass `turnId`: a value that changes per inbound MESSAGE (a Gmail `messageId`, a webhook
|
|
191
|
+
* delivery id…). Like `turn.start`, the row is once per turn because the discriminator is a per-turn
|
|
192
|
+
* IDENTITY, not a time window, so a sustained campaign down one thread produces one row per message while a
|
|
193
|
+
* turn retried internally still collapses to one. `sessionId` is a poor key on its own — a Gmail `sessionId`
|
|
194
|
+
* is per-THREAD and permanent, so every later suppression in that thread fell inside the dedupe window and
|
|
195
|
+
* vanished. Omitting `turnId` keeps the old (per-session) behavior for callers that have no per-message id.
|
|
196
|
+
*/
|
|
197
|
+
export function mayReplyTo(principal: Principal, ctx: { sessionId?: string; turnId?: string; channel?: string } = {}): boolean {
|
|
198
|
+
try {
|
|
199
|
+
if (getAgentConfig().allowUntrustedReplies === true) return true;
|
|
200
|
+
const rt = current;
|
|
201
|
+
if (!rt || !rt.policy.valid) return false;
|
|
202
|
+
// A fail-closed policy keeps only owner+anonymous, and resolving an unknown role falls back to the
|
|
203
|
+
// DEFAULT role — whose rank would wrongly admit a guest. So require a real `member` role.
|
|
204
|
+
const member = rt.policy.effective(Infinity, MEMBER_ROLE);
|
|
205
|
+
if (member.role !== MEMBER_ROLE) return false;
|
|
206
|
+
const r = resolvePrincipal(rt.policy, principal);
|
|
207
|
+
if (principal.verified && r.rank >= member.rank) return true;
|
|
208
|
+
rt.record({
|
|
209
|
+
type: 'guard.limit', principal: principal.id, role: r.role, sessionId: ctx.sessionId,
|
|
210
|
+
target: ctx.channel ?? 'reply', reason: 'untrusted-reply',
|
|
211
|
+
meta: { kind: principal.kind, verified: principal.verified, rank: r.rank, memberRank: member.rank },
|
|
212
|
+
}, `reply.suppressed|${ctx.turnId ?? ctx.sessionId ?? principal.id}`);
|
|
213
|
+
return false;
|
|
214
|
+
} catch (e: any) {
|
|
215
|
+
console.error(`[security] mayReplyTo threw — suppressing the reply: ${e?.message ?? e}`);
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
168
220
|
/** Client IP of a request/upgrade, honoring TRUSTED_PROXIES. */
|
|
169
221
|
export function requestIp(req: Parameters<Guard['ipOf']>[0]): string | undefined {
|
|
170
222
|
return current?.guard.ipOf(req);
|
package/src/server/sessions.ts
CHANGED
|
@@ -576,6 +576,9 @@ export function getSessionAbortController(sessionId: string): AbortController |
|
|
|
576
576
|
return globalSessionLocks.get(sessionId)?.abortController;
|
|
577
577
|
}
|
|
578
578
|
|
|
579
|
+
// Deleting a conversation is deliberately not exposed by the app (no route, no helper): the audit log is
|
|
580
|
+
// append-only and an owner who really means to remove one does it by hand on the box.
|
|
581
|
+
|
|
579
582
|
function findSessionFile(sessionId: string): string | null {
|
|
580
583
|
return findClaudeTranscript(sessionId, path.join(homedir(), '.claude'));
|
|
581
584
|
}
|
|
@@ -55,6 +55,19 @@ export interface AgentSettings {
|
|
|
55
55
|
/** claude-code engine: resume the SDK session across turns instead of re-sending the history
|
|
56
56
|
* (prompt-cache savings). Default off; a session's `[resume:on|off]` directive wins. */
|
|
57
57
|
sdkResume?: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Allow an AUTOMATIC outbound reply to a low-trust inbound principal — one whose resolved role ranks
|
|
60
|
+
* below `member` (guest, anonymous, an unverified sender). Default OFF.
|
|
61
|
+
*
|
|
62
|
+
* Off does NOT drop the message: the turn still runs and `escalate` still reaches owners. Only the
|
|
63
|
+
* automatic reply back to that sender is suppressed, because a turn's prompt carries roster/skill/
|
|
64
|
+
* workspace context and a channel that mails the model's text back to an unverified sender is a
|
|
65
|
+
* disclosure path. A human operator's own turns are never gated by this.
|
|
66
|
+
*
|
|
67
|
+
* Orthogonal to `SECURITY_ENFORCE`: this gates OUTBOUND REPLIES, not tools, so it applies whether or
|
|
68
|
+
* not enforcement is on (shadow mode included). Channels/add-ons ask `mayReplyTo(principal)`.
|
|
69
|
+
*/
|
|
70
|
+
allowUntrustedReplies?: boolean;
|
|
58
71
|
}
|
|
59
72
|
|
|
60
73
|
export interface ShragaConfig {
|