@sudobility/sider_lib 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,199 @@
1
+ // Tokenizer (browser-side, but PURE here — the caller supplies the secret
2
+ // values it discovered in the user's own cookie jar / storage). Replaces secrets
3
+ // in a raw observation with a readable placeholder "{{secret:<slotId>}}" and
4
+ // emits SecretSlot proposals — so nothing with a real secret leaves the browser.
5
+ //
6
+ // Coverage (defense in depth, because storage cross-reference alone misses
7
+ // secrets minted this session or carried in bodies / non-standard headers):
8
+ // 1. Known values gathered from the user's own cookies/storage (exact match).
9
+ // 2. Any request header whose NAME looks credential-bearing.
10
+ // 3. Body fields whose KEY looks credential-bearing (e.g. access_token).
11
+ // 4. JWT-shaped strings anywhere in a body.
12
+ // Bias is conservative-for-privacy: over-mask rather than leak.
13
+ export function samplePlaceholder(slotId) {
14
+ return `{{secret:${slotId}}}`;
15
+ }
16
+ // Header NAME looks credential-bearing (conservative-for-privacy: any "key",
17
+ // "token", "auth", "secret", "session", etc. in the name).
18
+ const SECRET_HEADER_RE = /authorization|cookie|token|key|secret|session|auth|xsrf|csrf|bearer/i;
19
+ // Body property KEY looks credential-bearing.
20
+ const SECRET_KEY_RE = /(^|[_-])(access_token|refresh_token|id_token|token|api[-_]?key|apikey|client_secret|secret|password|passwd|session_?(id|token)|jwt|bearer|credential|auth_?token)($|[_-])/i;
21
+ // JWT-shaped string.
22
+ const JWT_RE = /eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{2,}/g;
23
+ const MIN_MASK_LEN = 6;
24
+ export function tokenizeObservation(raw, known) {
25
+ const origin = originOf(raw.url);
26
+ const host = hostOf(origin);
27
+ const slots = new Map();
28
+ let url = raw.url;
29
+ const headers = { ...raw.requestHeaders };
30
+ let requestBody = raw.requestBody;
31
+ let responseBody = raw.responseBody;
32
+ const ensure = (slotId, kind, role, loc, hint) => {
33
+ if (slots.has(slotId))
34
+ return;
35
+ slots.set(slotId, {
36
+ id: slotId,
37
+ kind,
38
+ role,
39
+ injectionLocation: loc,
40
+ allowedDestination: origin,
41
+ resolverHints: hint ? [hint] : [],
42
+ confidence: 0.6,
43
+ });
44
+ };
45
+ // 1. Known values (exact substring match everywhere).
46
+ for (const s of known) {
47
+ if (!s.value)
48
+ continue;
49
+ const loc = locate(raw, s.value) ?? { at: "header", name: "authorization" };
50
+ const ph = samplePlaceholder(s.slotId);
51
+ url = url.split(s.value).join(ph);
52
+ for (const k of Object.keys(headers))
53
+ headers[k] = headers[k].split(s.value).join(ph);
54
+ requestBody = replaceInJson(requestBody, s.value, ph);
55
+ responseBody = replaceInJson(responseBody, s.value, ph);
56
+ ensure(s.slotId, s.kind, s.role, loc, s.resolverHint);
57
+ }
58
+ // 2. Credential-bearing headers (by name), even when the value wasn't in storage.
59
+ for (const name of Object.keys(headers)) {
60
+ const val = headers[name];
61
+ if (typeof val !== "string" || !val || val.startsWith("{{secret:"))
62
+ continue;
63
+ if (!SECRET_HEADER_RE.test(name))
64
+ continue;
65
+ const lname = name.toLowerCase();
66
+ const slotId = `slot:${host}:${normalize(name)}`;
67
+ headers[name] = samplePlaceholder(slotId);
68
+ ensure(slotId, guessKind(name), lname === "cookie" ? "auto_cookie" : "active_inject", lname === "cookie" ? { at: "cookie", name } : { at: "header", name });
69
+ }
70
+ // 3 + 4. Body fields by key + JWTs anywhere.
71
+ requestBody = maskBody(requestBody, host, origin, slots, ensure, "");
72
+ responseBody = maskBody(responseBody, host, origin, slots, ensure, "");
73
+ return {
74
+ observation: {
75
+ method: raw.method,
76
+ url,
77
+ requestHeaders: headers,
78
+ requestBody,
79
+ status: raw.status,
80
+ responseBody,
81
+ timingMs: raw.timingMs,
82
+ context: raw.context,
83
+ },
84
+ slots: [...slots.values()],
85
+ };
86
+ }
87
+ function maskBody(node, host, origin, slots, ensure, path, key) {
88
+ if (node === undefined || node === null)
89
+ return node;
90
+ if (typeof node === "string") {
91
+ if (key && node.length >= MIN_MASK_LEN && SECRET_KEY_RE.test(key)) {
92
+ const slotId = `slot:${host}:${normalize(key)}`;
93
+ ensure(slotId, guessKind(key), "active_inject", { at: "body", jsonPath: path });
94
+ return samplePlaceholder(slotId);
95
+ }
96
+ if (JWT_RE.test(node)) {
97
+ JWT_RE.lastIndex = 0;
98
+ const slotId = `slot:${host}:jwt`;
99
+ ensure(slotId, "bearer", "active_inject", { at: "body", jsonPath: path });
100
+ return node.replace(JWT_RE, samplePlaceholder(slotId));
101
+ }
102
+ return node;
103
+ }
104
+ if (Array.isArray(node))
105
+ return node.map((n, i) => maskBody(n, host, origin, slots, ensure, `${path}[${i}]`, key));
106
+ if (typeof node === "object") {
107
+ const out = {};
108
+ for (const [k, v] of Object.entries(node))
109
+ out[k] = maskBody(v, host, origin, slots, ensure, path ? `${path}.${k}` : k, k);
110
+ return out;
111
+ }
112
+ return node;
113
+ }
114
+ // --- helpers ---------------------------------------------------------------
115
+ function guessKind(name) {
116
+ const n = name.toLowerCase();
117
+ if (n.includes("csrf") || n.includes("xsrf"))
118
+ return "csrf";
119
+ if (n.includes("cookie") || n.includes("session"))
120
+ return "session";
121
+ if (n.includes("auth") || n.includes("bearer") || n.includes("token") || n.includes("jwt"))
122
+ return "bearer";
123
+ if (n.includes("api") && n.includes("key"))
124
+ return "api_key";
125
+ return "unknown";
126
+ }
127
+ function locate(raw, value) {
128
+ for (const [name, v] of Object.entries(raw.requestHeaders)) {
129
+ if (typeof v === "string" && v.includes(value))
130
+ return { at: "header", name };
131
+ }
132
+ try {
133
+ const u = new URL(raw.url, "http://sider.local");
134
+ for (const [param, v] of u.searchParams)
135
+ if (v.includes(value))
136
+ return { at: "query", param };
137
+ }
138
+ catch {
139
+ /* ignore */
140
+ }
141
+ const bodyPath = findInJson(raw.requestBody, value);
142
+ if (bodyPath)
143
+ return { at: "body", jsonPath: bodyPath };
144
+ return null;
145
+ }
146
+ function findInJson(node, value, prefix = "") {
147
+ if (typeof node === "string")
148
+ return node.includes(value) ? prefix.replace(/^\./, "") : null;
149
+ if (Array.isArray(node)) {
150
+ for (let i = 0; i < node.length; i++) {
151
+ const p = findInJson(node[i], value, `${prefix}[${i}]`);
152
+ if (p)
153
+ return p;
154
+ }
155
+ return null;
156
+ }
157
+ if (node && typeof node === "object") {
158
+ for (const [k, v] of Object.entries(node)) {
159
+ const p = findInJson(v, value, `${prefix}.${k}`);
160
+ if (p)
161
+ return p;
162
+ }
163
+ }
164
+ return null;
165
+ }
166
+ function replaceInJson(node, value, ph) {
167
+ if (node === undefined)
168
+ return node;
169
+ if (typeof node === "string")
170
+ return node.split(value).join(ph);
171
+ if (Array.isArray(node))
172
+ return node.map((n) => replaceInJson(n, value, ph));
173
+ if (node && typeof node === "object") {
174
+ const out = {};
175
+ for (const [k, v] of Object.entries(node))
176
+ out[k] = replaceInJson(v, value, ph);
177
+ return out;
178
+ }
179
+ return node;
180
+ }
181
+ function originOf(url) {
182
+ try {
183
+ return new URL(url).origin;
184
+ }
185
+ catch {
186
+ return url;
187
+ }
188
+ }
189
+ function hostOf(origin) {
190
+ try {
191
+ return new URL(origin).host;
192
+ }
193
+ catch {
194
+ return origin;
195
+ }
196
+ }
197
+ function normalize(s) {
198
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, "_");
199
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,42 @@
1
+ import { test, expect } from "bun:test";
2
+ import { tokenizeObservation } from "./tokenize";
3
+ function raw(overrides = {}) {
4
+ return {
5
+ method: "GET",
6
+ url: "https://resale.fifa.com/api/seats",
7
+ requestHeaders: {},
8
+ requestBody: undefined,
9
+ status: 200,
10
+ responseBody: undefined,
11
+ timingMs: 10,
12
+ context: { route: "/seats" },
13
+ ...overrides,
14
+ };
15
+ }
16
+ test("masks a credential-named request header", () => {
17
+ const { observation, slots } = tokenizeObservation(raw({ requestHeaders: { authorization: "Bearer abc.def.ghi" } }), []);
18
+ expect(JSON.stringify(observation.requestHeaders)).not.toContain("abc.def.ghi");
19
+ expect(slots.length).toBeGreaterThan(0);
20
+ });
21
+ test("masks a known cookie/storage value wherever it appears in a body", () => {
22
+ const known = [
23
+ {
24
+ value: "SUPERSECRETVALUE123",
25
+ slotId: "slot:resale.fifa.com:session",
26
+ kind: "session",
27
+ role: "active_inject",
28
+ resolverHint: { from: "cookie", name: "sid" },
29
+ },
30
+ ];
31
+ const { observation } = tokenizeObservation(raw({ responseBody: { echoed: "SUPERSECRETVALUE123", ok: true } }), known);
32
+ expect(JSON.stringify(observation.responseBody)).not.toContain("SUPERSECRETVALUE123");
33
+ });
34
+ test("masks a JWT-shaped string in a body", () => {
35
+ const jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.abc123signature";
36
+ const { observation } = tokenizeObservation(raw({ responseBody: { token: jwt } }), []);
37
+ expect(JSON.stringify(observation.responseBody)).not.toContain(jwt);
38
+ });
39
+ test("leaves non-secret data untouched", () => {
40
+ const { observation } = tokenizeObservation(raw({ responseBody: { price: 400, section: "A1" } }), []);
41
+ expect(observation.responseBody).toEqual({ price: 400, section: "A1" });
42
+ });
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@sudobility/sider_lib",
3
+ "version": "0.0.2",
4
+ "description": "Sider business logic — recipe compiler, egress gates, tokenizer, clustering. Pure, no Chrome/DOM/server.",
5
+ "license": "BUSL-1.1",
6
+ "publishConfig": {
7
+ "access": "restricted"
8
+ },
9
+ "type": "module",
10
+ "main": "src/index.ts",
11
+ "types": "src/index.ts",
12
+ "files": [
13
+ "src",
14
+ "dist"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc -p tsconfig.json",
18
+ "typecheck": "tsc --noEmit",
19
+ "test": "bun test"
20
+ },
21
+ "dependencies": {
22
+ "@sudobility/sider_types": "^0.0.2"
23
+ },
24
+ "devDependencies": {
25
+ "@types/bun": "^1.3.14",
26
+ "typescript": "^5.9.3"
27
+ }
28
+ }
@@ -0,0 +1,86 @@
1
+ // Composite/fan-out tool execution. A ToolSpec whose recipe has `compose` steps
2
+ // chains or fans out over other tools — e.g. "list sections → for each, fetch
3
+ // seats". This is PURE orchestration: the caller injects `run` (which applies
4
+ // the three gates + issues the actual request for one tool), and this threads
5
+ // prior outputs, resolves bindings, and parallelizes fan-out.
6
+
7
+ import type { ComposeStep, ParamBinding, ToolSpec } from "@sudobility/sider_types";
8
+ import { getByPath } from "./recipe";
9
+
10
+ export interface RunStepResult {
11
+ ok: boolean;
12
+ status?: number;
13
+ body?: unknown;
14
+ error?: string;
15
+ gate?: string;
16
+ }
17
+
18
+ /** Runs ONE tool by id with resolved args. The caller applies gates + fetch. */
19
+ export type StepRunner = (toolId: string, args: Record<string, unknown>) => Promise<RunStepResult>;
20
+
21
+ /** Reference, in a priorOutput binding, to the current element during a fan-out. */
22
+ const ITEM_REF = "$item";
23
+
24
+ export async function executeComposite(
25
+ tool: ToolSpec,
26
+ ctx: { args?: Record<string, unknown> },
27
+ run: StepRunner,
28
+ ): Promise<RunStepResult> {
29
+ const steps = tool.recipe.compose ?? [];
30
+ if (steps.length === 0) return run(tool.id, ctx.args ?? {});
31
+
32
+ const priorOutputs: Record<string, unknown> = {};
33
+ let last: RunStepResult | undefined;
34
+
35
+ for (const step of steps) {
36
+ if (step.forEachJsonPath) {
37
+ // Fan out: run the step once per element of the referenced array (parallel).
38
+ const arr = getByPath(priorOutputs, step.forEachJsonPath);
39
+ const items = Array.isArray(arr) ? arr : [];
40
+ const results = await Promise.all(
41
+ items.map((item) => run(step.toolId, resolveArgs(step.argBindings, ctx, priorOutputs, item))),
42
+ );
43
+ priorOutputs[step.ref] = results.map((r) => r.body);
44
+ last = { ok: results.every((r) => r.ok), body: results.map((r) => r.body) };
45
+ if (!last.ok) break; // don't thread a failed fan-out into later steps
46
+ } else {
47
+ const r = await run(step.toolId, resolveArgs(step.argBindings, ctx, priorOutputs, undefined));
48
+ priorOutputs[step.ref] = r.body;
49
+ last = r;
50
+ if (!r.ok) break; // stop the chain on the first failure
51
+ }
52
+ }
53
+
54
+ return last ?? { ok: false, error: "empty compose" };
55
+ }
56
+
57
+ function resolveArgs(
58
+ bindings: Record<string, ParamBinding>,
59
+ ctx: { args?: Record<string, unknown> },
60
+ priorOutputs: Record<string, unknown>,
61
+ item: unknown,
62
+ ): Record<string, unknown> {
63
+ const out: Record<string, unknown> = {};
64
+ for (const [key, b] of Object.entries(bindings)) {
65
+ switch (b.kind) {
66
+ case "literal":
67
+ out[key] = b.value;
68
+ break;
69
+ case "arg":
70
+ out[key] = ctx.args?.[b.argName];
71
+ break;
72
+ case "priorOutput":
73
+ out[key] =
74
+ b.stepRef === ITEM_REF ? getByPath(item, b.jsonPath) : getByPath(priorOutputs[b.stepRef], b.jsonPath);
75
+ break;
76
+ case "secret":
77
+ // Secrets belong to the sub-tool's own recipe (resolved at egress by
78
+ // the runner), never to compose-level args.
79
+ break;
80
+ }
81
+ }
82
+ return out;
83
+ }
84
+
85
+ // keep the fan-out sentinel discoverable to callers/authors of recipes.
86
+ export { ITEM_REF };
package/src/execute.ts ADDED
@@ -0,0 +1,89 @@
1
+ // Detokenization (the last mile, browser-side) + the single prepareRequest
2
+ // entry point. Detokenization runs ONLY after the gates pass; it reads each
3
+ // secret's current value from the caller's resolver (backed by the browser
4
+ // TokenMap) and substitutes it into the sentinel positions the gates already
5
+ // proved are confined to the declared location.
6
+
7
+ import type { CompiledRequest, SecretSlot } from "@sudobility/sider_types";
8
+ import { compileRecipe, secretSentinel } from "./recipe";
9
+ import { evaluateGates } from "./gates";
10
+ import type { ToolSpec } from "@sudobility/sider_types";
11
+ import type { CompileContext } from "./recipe";
12
+ import type { GateContext } from "./gates";
13
+
14
+ export interface ResolvedRequest {
15
+ method: CompiledRequest["method"];
16
+ url: string;
17
+ headers: Record<string, string>;
18
+ body?: unknown;
19
+ credentials: "include";
20
+ }
21
+
22
+ /** Resolve a slot's current value from the caller's live session (never stored). */
23
+ export type SecretResolver = (slotId: string) => string | undefined;
24
+
25
+ export function detokenizeRequest(
26
+ req: CompiledRequest,
27
+ resolve: SecretResolver,
28
+ slotsById: Record<string, SecretSlot>,
29
+ ): ResolvedRequest {
30
+ let url = req.url;
31
+ const headers: Record<string, string> = { ...req.headers };
32
+ let bodyStr = req.body !== undefined ? JSON.stringify(req.body) : undefined;
33
+
34
+ for (const slotId of req.referencedSlotIds) {
35
+ const slot = slotsById[slotId];
36
+ // auto_cookie secrets are attached by the browser; never substituted.
37
+ if (slot && (slot.role === "auto_cookie" || slot.injectionLocation.at === "cookie")) continue;
38
+
39
+ const value = resolve(slotId);
40
+ if (value === undefined) throw new Error(`Unresolved secret slot at egress: ${slotId}`);
41
+
42
+ const sen = secretSentinel(slotId);
43
+ url = url.split(sen).join(value);
44
+ for (const k of Object.keys(headers)) headers[k] = headers[k]!.split(sen).join(value);
45
+ if (bodyStr !== undefined) {
46
+ const innerEscaped = JSON.stringify(value).slice(1, -1); // JSON-string-safe
47
+ bodyStr = bodyStr.split(sen).join(innerEscaped);
48
+ }
49
+ }
50
+
51
+ return {
52
+ method: req.method,
53
+ url,
54
+ headers,
55
+ body: bodyStr !== undefined ? JSON.parse(bodyStr) : undefined,
56
+ credentials: "include",
57
+ };
58
+ }
59
+
60
+ export type PrepareResult =
61
+ | { ok: true; request: ResolvedRequest }
62
+ | { ok: false; gate: "compile" | "same_origin" | "injection_location" | "safety"; reason: string };
63
+
64
+ /**
65
+ * compile → gate → detokenize, in one call. The browser executor should issue
66
+ * `result.request` only when `result.ok`.
67
+ */
68
+ export function prepareRequest(
69
+ tool: ToolSpec,
70
+ compileCtx: CompileContext,
71
+ gateCtx: GateContext,
72
+ resolve: SecretResolver,
73
+ ): PrepareResult {
74
+ let compiled: CompiledRequest;
75
+ try {
76
+ compiled = compileRecipe(tool, compileCtx);
77
+ } catch (e) {
78
+ return { ok: false, gate: "compile", reason: (e as Error).message };
79
+ }
80
+
81
+ const gate = evaluateGates(compiled, gateCtx);
82
+ if (!gate.ok) return gate;
83
+
84
+ try {
85
+ return { ok: true, request: detokenizeRequest(compiled, resolve, gateCtx.slotsById) };
86
+ } catch (e) {
87
+ return { ok: false, gate: "injection_location", reason: (e as Error).message };
88
+ }
89
+ }
@@ -0,0 +1,98 @@
1
+ import { test, expect } from "bun:test";
2
+ import { evaluateGates } from "./gates";
3
+ import { secretSentinel } from "./recipe";
4
+ import type { CompiledRequest, SecretSlot } from "@sudobility/sider_types";
5
+
6
+ const ORIGIN = "https://resale.fifa.com";
7
+
8
+ function slot(overrides: Partial<SecretSlot> = {}): SecretSlot {
9
+ return {
10
+ id: "slot:resale.fifa.com:auth_bearer",
11
+ siteId: "site1",
12
+ kind: "bearer",
13
+ role: "active_inject",
14
+ injectionLocation: { at: "header", name: "authorization" },
15
+ allowedDestination: ORIGIN,
16
+ resolverHints: [],
17
+ confidence: 1,
18
+ createdAt: "2026-01-01T00:00:00.000Z",
19
+ updatedAt: "2026-01-01T00:00:00.000Z",
20
+ ...overrides,
21
+ };
22
+ }
23
+
24
+ function req(overrides: Partial<CompiledRequest> = {}): CompiledRequest {
25
+ const sen = secretSentinel("slot:resale.fifa.com:auth_bearer");
26
+ return {
27
+ method: "GET",
28
+ url: `${ORIGIN}/api/seats`,
29
+ headers: { authorization: `Bearer ${sen}` },
30
+ body: undefined,
31
+ credentials: "include",
32
+ referencedSlotIds: ["slot:resale.fifa.com:auth_bearer"],
33
+ safetyClass: "read",
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ const ctx = (extra: Partial<Parameters<typeof evaluateGates>[1]> = {}) => ({
39
+ siteOrigin: ORIGIN,
40
+ slotsById: { "slot:resale.fifa.com:auth_bearer": slot() },
41
+ ...extra,
42
+ });
43
+
44
+ test("Gate A: passes a same-origin read with a confined header secret", () => {
45
+ expect(evaluateGates(req(), ctx())).toEqual({ ok: true });
46
+ });
47
+
48
+ test("Gate A: refuses a cross-origin destination", () => {
49
+ const out = evaluateGates(req({ url: "https://evil.com/api/seats" }), ctx());
50
+ expect(out.ok).toBe(false);
51
+ if (!out.ok) expect(out.gate).toBe("same_origin");
52
+ });
53
+
54
+ test("Gate A: refuses when the slot's allowedDestination differs", () => {
55
+ const s = slot({ allowedDestination: "https://other.com" });
56
+ const out = evaluateGates(req(), ctx({ slotsById: { [s.id]: s } }));
57
+ expect(out.ok).toBe(false);
58
+ if (!out.ok) expect(out.gate).toBe("same_origin");
59
+ });
60
+
61
+ test("Gate B: refuses a sentinel relocated into the URL", () => {
62
+ const sen = secretSentinel("slot:resale.fifa.com:auth_bearer");
63
+ const out = evaluateGates(
64
+ req({ url: `${ORIGIN}/api/seats?t=${sen}`, headers: { authorization: `Bearer ${sen}` } }),
65
+ ctx(),
66
+ );
67
+ expect(out.ok).toBe(false);
68
+ if (!out.ok) expect(out.gate).toBe("injection_location");
69
+ });
70
+
71
+ test("Gate B: refuses when the sentinel is absent from its declared location", () => {
72
+ const out = evaluateGates(req({ headers: { authorization: "Bearer plain" } }), ctx());
73
+ expect(out.ok).toBe(false);
74
+ if (!out.ok) expect(out.gate).toBe("injection_location");
75
+ });
76
+
77
+ test("Gate C: a POST (method-bound) requires confirmation even if labeled read", () => {
78
+ const out = evaluateGates(req({ method: "POST", safetyClass: "read" }), ctx());
79
+ expect(out.ok).toBe(false);
80
+ if (!out.ok) expect(out.gate).toBe("safety");
81
+ });
82
+
83
+ test("Gate C: a confirmed POST passes", () => {
84
+ expect(evaluateGates(req({ method: "POST" }), ctx({ confirmed: true }))).toEqual({ ok: true });
85
+ });
86
+
87
+ test("Gate C: allowMutations=false blocks a mutation outright", () => {
88
+ const out = evaluateGates(req({ method: "POST" }), ctx({ confirmed: true, allowMutations: false }));
89
+ expect(out.ok).toBe(false);
90
+ if (!out.ok) expect(out.gate).toBe("safety");
91
+ });
92
+
93
+ test("cookie-borne slot is refused (must be auto-attached, never injected)", () => {
94
+ const s = slot({ role: "auto_cookie", injectionLocation: { at: "cookie", name: "sid" } });
95
+ const out = evaluateGates(req(), ctx({ slotsById: { [s.id]: s } }));
96
+ expect(out.ok).toBe(false);
97
+ if (!out.ok) expect(out.gate).toBe("injection_location");
98
+ });
package/src/gates.ts ADDED
@@ -0,0 +1,124 @@
1
+ // The three egress gates. This is the security core: tokenization is only safe
2
+ // BECAUSE a secret cannot leave for the wrong destination (Gate A), cannot be
3
+ // relocated out of its declared slot (Gate B), and cannot ride a mutation
4
+ // without confirmation (Gate C). Run on a CompiledRequest whose secrets are
5
+ // still sentinels — never on values.
6
+
7
+ import type { CompiledRequest, GateOutcome, SecretSlot } from "@sudobility/sider_types";
8
+ import { getByPath, secretSentinel } from "./recipe";
9
+
10
+ export interface GateContext {
11
+ /** The site's origin. The request destination MUST equal this. */
12
+ siteOrigin: string;
13
+ /** Every secret slot the request may reference, keyed by slot id (placeholder). */
14
+ slotsById: Record<string, SecretSlot>;
15
+ /** True once the user has confirmed a write/financial step. */
16
+ confirmed?: boolean;
17
+ /** Site policy: whether mutations are permitted here at all. */
18
+ allowMutations?: boolean;
19
+ }
20
+
21
+ export function evaluateGates(req: CompiledRequest, ctx: GateContext): GateOutcome {
22
+ // --- Gate A: same-origin destination ------------------------------------
23
+ let originHost: string;
24
+ try {
25
+ originHost = new URL(req.url).origin;
26
+ } catch {
27
+ return { ok: false, gate: "same_origin", reason: `invalid url: ${req.url}` };
28
+ }
29
+ if (originHost !== ctx.siteOrigin) {
30
+ return {
31
+ ok: false,
32
+ gate: "same_origin",
33
+ reason: `destination ${originHost} != site ${ctx.siteOrigin}`,
34
+ };
35
+ }
36
+
37
+ for (const slotId of req.referencedSlotIds) {
38
+ const slot = ctx.slotsById[slotId];
39
+ if (!slot) {
40
+ return { ok: false, gate: "injection_location", reason: `unknown secret slot ${slotId}` };
41
+ }
42
+ if (slot.allowedDestination !== originHost) {
43
+ return {
44
+ ok: false,
45
+ gate: "same_origin",
46
+ reason: `slot ${slotId} allows ${slot.allowedDestination}, not ${originHost}`,
47
+ };
48
+ }
49
+ if (slot.role === "auto_cookie" || slot.injectionLocation.at === "cookie") {
50
+ return {
51
+ ok: false,
52
+ gate: "injection_location",
53
+ reason: `slot ${slotId} is cookie-borne (auto-attached); it must not be injected`,
54
+ };
55
+ }
56
+
57
+ // --- Gate B: sentinel appears ONLY in its declared location ------------
58
+ const b = injectionCheck(req, slot);
59
+ if (!b.ok) return b;
60
+ }
61
+
62
+ // --- Gate C: mutations require confirmation -----------------------------
63
+ // Bind to the HTTP method, not just the server-declared (and therefore
64
+ // untrusted) safetyClass — a recipe labeled "read" with a POST/PUT/PATCH/
65
+ // DELETE still mutates and must not run unconfirmed.
66
+ const idempotent = req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS";
67
+ if (req.safetyClass !== "read" || !idempotent) {
68
+ if (ctx.allowMutations === false) {
69
+ return { ok: false, gate: "safety", reason: `mutations disabled for ${ctx.siteOrigin}` };
70
+ }
71
+ if (!ctx.confirmed) {
72
+ return { ok: false, gate: "safety", reason: `mutating request requires confirmation` };
73
+ }
74
+ }
75
+
76
+ return { ok: true };
77
+ }
78
+
79
+ function injectionCheck(req: CompiledRequest, slot: SecretSlot): GateOutcome {
80
+ const sen = secretSentinel(slot.id);
81
+ const bodyStr = req.body !== undefined ? JSON.stringify(req.body) : "";
82
+
83
+ const total =
84
+ count(req.url, sen) +
85
+ Object.values(req.headers).reduce((n, v) => n + count(v, sen), 0) +
86
+ count(bodyStr, sen);
87
+
88
+ let allowed = 0;
89
+ const loc = slot.injectionLocation;
90
+ if (loc.at === "header") {
91
+ allowed = count(req.headers[loc.name] ?? "", sen);
92
+ } else if (loc.at === "query") {
93
+ let qv = "";
94
+ try {
95
+ qv = new URL(req.url).searchParams.get(loc.param) ?? "";
96
+ } catch {
97
+ /* invalid url already caught in Gate A */
98
+ }
99
+ allowed = count(qv, sen);
100
+ } else if (loc.at === "body") {
101
+ allowed = count(String(getByPath(req.body, loc.jsonPath) ?? ""), sen);
102
+ }
103
+
104
+ if (allowed < 1) {
105
+ return {
106
+ ok: false,
107
+ gate: "injection_location",
108
+ reason: `slot ${slot.id} absent from its declared ${loc.at} location`,
109
+ };
110
+ }
111
+ if (total !== allowed) {
112
+ return {
113
+ ok: false,
114
+ gate: "injection_location",
115
+ reason: `slot ${slot.id} appears outside its declared ${loc.at} location`,
116
+ };
117
+ }
118
+ return { ok: true };
119
+ }
120
+
121
+ function count(hay: string, needle: string): number {
122
+ if (!needle) return 0;
123
+ return hay.split(needle).length - 1;
124
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ // @sudobility/sider_lib — pure Sider business logic.
2
+ //
3
+ // templating — cluster raw observations into endpoint templates
4
+ // tokenize — replace known secret values with placeholders (browser-side)
5
+ // recipe — compile a ToolSpec + args into a CompiledRequest (secrets = sentinels)
6
+ // gates — the three egress gates (same-origin / injection-location / safety)
7
+ // execute — detokenize after gates pass; prepareRequest = compile→gate→detokenize
8
+ //
9
+ // No Chrome/DOM/server dependencies — safe to import from sider_api,
10
+ // sider_extension, and sider_app alike.
11
+
12
+ export * from "./templating";
13
+ export * from "./tokenize";
14
+ export * from "./recipe";
15
+ export * from "./gates";
16
+ export * from "./execute";
17
+ export * from "./composite";