@fractalcode/bridge 0.1.0

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,137 @@
1
+ "use strict";
2
+ /**
3
+ * fractal-openshell-bridge — the pure compiler function.
4
+ *
5
+ * `compileSidecars(channels, timestamp)` is a pure function:
6
+ * - No filesystem, no network, no env, no clock reads (timestamp is a param).
7
+ * - Input is deep-frozen on entry (downstream mappers cannot mutate it).
8
+ * - Output is deterministic: identical channels → byte-identical policyYaml
9
+ * → identical policyHash. The timestamp is NOT an input to the hash.
10
+ * - Errors are returned as { success: false, code, error }, never thrown.
11
+ *
12
+ * Side effects (logging, persistence) are the caller's responsibility — the
13
+ * caller receives the auditLog in the result and persists it however it likes.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.buildFilesystem = exports.mapChannel = exports.serializePolicy = void 0;
17
+ exports.compileSidecars = compileSidecars;
18
+ const crypto_1 = require("crypto");
19
+ const mappings_1 = require("./mappings");
20
+ const serialize_1 = require("./serialize");
21
+ /** Recursively freeze an object so downstream code cannot mutate the input. */
22
+ function deepFreeze(value) {
23
+ if (value && typeof value === "object") {
24
+ Object.freeze(value);
25
+ for (const v of Object.values(value)) {
26
+ deepFreeze(v);
27
+ }
28
+ }
29
+ return value;
30
+ }
31
+ /** Validate that input is an array of TypedChannelConfig-shaped objects. */
32
+ function validateInput(channels) {
33
+ if (!Array.isArray(channels)) {
34
+ return "compileSidecars: expected an array of TypedChannelConfig";
35
+ }
36
+ for (let i = 0; i < channels.length; i++) {
37
+ const c = channels[i];
38
+ if (!c || typeof c !== "object") {
39
+ return `compileSidecars: channels[${i}] is not an object`;
40
+ }
41
+ const obj = c;
42
+ if (typeof obj.name !== "string" || typeof obj.kind !== "string") {
43
+ return `compileSidecars: channels[${i}] missing string name/kind`;
44
+ }
45
+ if (!obj.scope || typeof obj.scope !== "object") {
46
+ return `compileSidecars: channels[${i}].scope is not an object`;
47
+ }
48
+ }
49
+ return null;
50
+ }
51
+ /**
52
+ * Compile an array of typed channel declarations into OpenShell policy YAML.
53
+ *
54
+ * @param channels Flattened channel declarations from `.channels.json` sidecars.
55
+ * @param timestamp Epoch milliseconds for the audit log (caller-supplied; NOT hashed).
56
+ * @returns CompilationResult — { success, policyYaml, policyHash, auditLog }
57
+ * on success, or { success: false, code, error } on failure.
58
+ */
59
+ function compileSidecars(channels, timestamp) {
60
+ const validationError = validateInput(channels);
61
+ if (validationError) {
62
+ return { success: false, code: "invalid-input", error: validationError };
63
+ }
64
+ // Deep-freeze so no mapper can inadvertently mutate the shared input.
65
+ const frozen = deepFreeze(structuredClone(channels));
66
+ const readWritePaths = [];
67
+ const readOnlyPaths = [];
68
+ const networkPolicies = {};
69
+ const anomalies = [];
70
+ const skippedChannels = [];
71
+ let channelsProcessed = 0;
72
+ // Deterministic processing order: sort by (kind, name) so output is stable
73
+ // regardless of input array order.
74
+ const ordered = [...frozen].sort((a, b) => {
75
+ const ka = `${a.kind}/${a.name}`;
76
+ const kb = `${b.kind}/${b.name}`;
77
+ return ka < kb ? -1 : ka > kb ? 1 : 0;
78
+ });
79
+ for (const channel of ordered) {
80
+ const result = (0, mappings_1.mapChannel)(channel);
81
+ if ("skipped" in result) {
82
+ skippedChannels.push(result.reason);
83
+ continue;
84
+ }
85
+ channelsProcessed += 1;
86
+ if (result.readWritePaths)
87
+ readWritePaths.push(...result.readWritePaths);
88
+ if (result.readOnlyPaths)
89
+ readOnlyPaths.push(...result.readOnlyPaths);
90
+ for (const [key, policy] of Object.entries(result.networkPolicies)) {
91
+ if (networkPolicies[key] !== undefined) {
92
+ anomalies.push(`network policy name collision on "${key}" — overwriting`);
93
+ }
94
+ networkPolicies[key] = policy;
95
+ }
96
+ anomalies.push(...result.anomalies);
97
+ }
98
+ const policy = {
99
+ version: 1,
100
+ filesystem_policy: (0, mappings_1.buildFilesystem)(readWritePaths, readOnlyPaths),
101
+ landlock: { compatibility: "best_effort" },
102
+ process: { run_as_user: "sandbox", run_as_group: "sandbox" },
103
+ network_policies: networkPolicies,
104
+ };
105
+ // Header comments surface mapping limitations for the human reading the YAML.
106
+ const headerComments = [
107
+ "Generated by fractal-openshell-bridge. Alpha, AI-built, not human-reviewed.",
108
+ "Read this policy before applying it. Anomalies are documented inline.",
109
+ ...anomalies,
110
+ ];
111
+ let policyYaml;
112
+ try {
113
+ policyYaml = (0, serialize_1.serializePolicy)(policy, headerComments);
114
+ }
115
+ catch (err) {
116
+ return {
117
+ success: false,
118
+ code: "serialize-failed",
119
+ error: `YAML serialization failed: ${err.message}`,
120
+ };
121
+ }
122
+ // SHA-256 over the YAML string ONLY. The timestamp (and thus the audit log)
123
+ // is deliberately excluded so identical input yields an identical hash.
124
+ const policyHash = (0, crypto_1.createHash)("sha256").update(policyYaml, "utf8").digest("hex");
125
+ const auditLog = {
126
+ timestamp,
127
+ channelsProcessed,
128
+ skippedChannels,
129
+ anomaliesDetected: anomalies,
130
+ };
131
+ return { success: true, policyYaml, policyHash, auditLog };
132
+ }
133
+ var serialize_2 = require("./serialize");
134
+ Object.defineProperty(exports, "serializePolicy", { enumerable: true, get: function () { return serialize_2.serializePolicy; } });
135
+ var mappings_2 = require("./mappings");
136
+ Object.defineProperty(exports, "mapChannel", { enumerable: true, get: function () { return mappings_2.mapChannel; } });
137
+ Object.defineProperty(exports, "buildFilesystem", { enumerable: true, get: function () { return mappings_2.buildFilesystem; } });
@@ -0,0 +1,31 @@
1
+ /**
2
+ * fractal-openshell-bridge — per-kind channel → OpenShell policy mapping
3
+ *
4
+ * Keepers: file, http, the 5 LLM channels, github, git, package.
5
+ *
6
+ * Architectural principle: a JS scope layer earns its keep when the platform
7
+ * has NO native privilege model. Channels that duplicate platform-native RBAC
8
+ * (postgres roles, stripe restricted keys, AWS IAM) are deliberately NOT
9
+ * mapped — they breed bypasses (see audit findings).
10
+ *
11
+ * Red-team corrections applied:
12
+ * - github: path narrowing now preserves the operation suffix (/repos/{repo}/pulls,
13
+ * not /repos/{repo}); one rule per (operation, repo).
14
+ * - git + package: added as keepers (dropping them silently broke coding-agent
15
+ * workflows; they have no strong native privilege model).
16
+ * - binaries: configurable via scope.binaries; per-kind defaults.
17
+ */
18
+ import type { TypedChannelConfig, FilesystemDef, NetworkPolicyRuleDef } from "./types";
19
+ export interface MappingContribution {
20
+ readWritePaths?: string[];
21
+ readOnlyPaths?: string[];
22
+ networkPolicies: Record<string, NetworkPolicyRuleDef>;
23
+ anomalies: string[];
24
+ }
25
+ export interface MappingSkip {
26
+ skipped: true;
27
+ reason: string;
28
+ }
29
+ export type MappingResult = MappingContribution | MappingSkip;
30
+ export declare function mapChannel(channel: TypedChannelConfig): MappingResult;
31
+ export declare function buildFilesystem(readWrite: string[], readOnly: string[]): FilesystemDef;
@@ -0,0 +1,316 @@
1
+ "use strict";
2
+ /**
3
+ * fractal-openshell-bridge — per-kind channel → OpenShell policy mapping
4
+ *
5
+ * Keepers: file, http, the 5 LLM channels, github, git, package.
6
+ *
7
+ * Architectural principle: a JS scope layer earns its keep when the platform
8
+ * has NO native privilege model. Channels that duplicate platform-native RBAC
9
+ * (postgres roles, stripe restricted keys, AWS IAM) are deliberately NOT
10
+ * mapped — they breed bypasses (see audit findings).
11
+ *
12
+ * Red-team corrections applied:
13
+ * - github: path narrowing now preserves the operation suffix (/repos/{repo}/pulls,
14
+ * not /repos/{repo}); one rule per (operation, repo).
15
+ * - git + package: added as keepers (dropping them silently broke coding-agent
16
+ * workflows; they have no strong native privilege model).
17
+ * - binaries: configurable via scope.binaries; per-kind defaults.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.mapChannel = mapChannel;
21
+ exports.buildFilesystem = buildFilesystem;
22
+ /** Default node binary path in the BYOC image (node:22-slim). */
23
+ const DEFAULT_BINARY = "/usr/local/bin/node";
24
+ /** LLM provider default API hosts (overridable via scope.baseUrl). */
25
+ const LLM_HOSTS = {
26
+ anthropic: "api.anthropic.com",
27
+ openai: "api.openai.com",
28
+ "google-genai": "generativelanguage.googleapis.com",
29
+ cohere: "api.cohere.com",
30
+ mistral: "api.mistral.ai",
31
+ };
32
+ /** GitHub operation → HTTP method + path template (with /** repo placeholder). */
33
+ const GITHUB_OP_MAP = {
34
+ createPR: { method: "POST", path: "/repos/**/pulls" },
35
+ mergePR: { method: "PUT", path: "/repos/**/pulls/*/merge" },
36
+ listPRs: { method: "GET", path: "/repos/**/pulls" },
37
+ getPR: { method: "GET", path: "/repos/**/pulls/*" },
38
+ createIssue: { method: "POST", path: "/repos/**/issues" },
39
+ listIssues: { method: "GET", path: "/repos/**/issues" },
40
+ getIssue: { method: "GET", path: "/repos/**/issues/*" },
41
+ commentOnIssue: { method: "POST", path: "/repos/**/issues/*/comments" },
42
+ closeIssue: { method: "PATCH", path: "/repos/**/issues/*" },
43
+ listRepos: { method: "GET", path: "/user/repos" },
44
+ getRepo: { method: "GET", path: "/repos/**" },
45
+ createRelease: { method: "POST", path: "/repos/**/releases" },
46
+ };
47
+ function str(v) {
48
+ return typeof v === "string" ? v : undefined;
49
+ }
50
+ function strArr(v) {
51
+ return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
52
+ }
53
+ /** Resolve binaries: scope.binaries overrides the per-kind fallback. */
54
+ function binariesFor(scope, fallback) {
55
+ const declared = strArr(scope.binaries);
56
+ return (declared.length > 0 ? declared : fallback).map((p) => ({ path: p }));
57
+ }
58
+ function hostFromBaseUrl(v) {
59
+ const u = str(v);
60
+ if (!u)
61
+ return undefined;
62
+ try {
63
+ return new URL(u).hostname;
64
+ }
65
+ catch {
66
+ return undefined;
67
+ }
68
+ }
69
+ /** Extract host from a git remote (https://, ssh://, or scp-like user@host:path). */
70
+ function hostFromRemote(remote) {
71
+ try {
72
+ if (/^https?:\/\//.test(remote))
73
+ return new URL(remote).hostname;
74
+ if (/^ssh:\/\//.test(remote))
75
+ return new URL(remote).hostname;
76
+ // scp-like: [user@]host:path
77
+ const m = remote.match(/^([^/@]+@)?([^/:]+):/);
78
+ if (m)
79
+ return m[2];
80
+ // bare host
81
+ return new URL(`https://${remote}`).hostname;
82
+ }
83
+ catch {
84
+ return undefined;
85
+ }
86
+ }
87
+ /** Extract host from a package registry (URL or bare host). */
88
+ function hostFromRegistry(reg) {
89
+ try {
90
+ return new URL(reg.startsWith("http") ? reg : `https://${reg}`).hostname;
91
+ }
92
+ catch {
93
+ return undefined;
94
+ }
95
+ }
96
+ function httpsEndpoint(host, rules, access) {
97
+ return {
98
+ host,
99
+ port: 443,
100
+ protocol: "rest",
101
+ tls: "terminate",
102
+ enforcement: "enforce",
103
+ ...(access ? { access } : {}),
104
+ rules,
105
+ };
106
+ }
107
+ function mapChannel(channel) {
108
+ const { kind, scope, name } = channel;
109
+ switch (kind) {
110
+ case "file": {
111
+ const allowedPaths = strArr(scope.allowedPaths);
112
+ const protectedPaths = strArr(scope.protectedPaths);
113
+ const anomalies = [];
114
+ if (protectedPaths.length > 0) {
115
+ anomalies.push(`file channel "${name}": protectedPaths ${JSON.stringify(protectedPaths)} cannot be expressed as a deny-list in OpenShell filesystem_policy. Ensure these paths are NOT granted write elsewhere; Landlock enforces the read_only/read_write split at the kernel level.`);
116
+ }
117
+ return { readWritePaths: allowedPaths, networkPolicies: {}, anomalies };
118
+ }
119
+ case "http": {
120
+ const allowedHosts = strArr(scope.allowedHosts);
121
+ const allowedMethods = strArr(scope.allowedMethods);
122
+ if (allowedHosts.length === 0) {
123
+ return { skipped: true, reason: `http channel "${name}" has no allowedHosts` };
124
+ }
125
+ const rules = allowedMethods.map((m) => ({
126
+ allow: { method: m.toUpperCase() },
127
+ }));
128
+ const endpoints = allowedHosts.map((h) => httpsEndpoint(h, rules));
129
+ return {
130
+ networkPolicies: {
131
+ [`writ_http_${name}`]: {
132
+ name: `writ_http_${name}`,
133
+ endpoints,
134
+ binaries: binariesFor(scope, [DEFAULT_BINARY]),
135
+ },
136
+ },
137
+ anomalies: [],
138
+ };
139
+ }
140
+ case "anthropic":
141
+ case "openai":
142
+ case "google-genai":
143
+ case "cohere":
144
+ case "mistral": {
145
+ const defaultHost = LLM_HOSTS[kind];
146
+ const host = hostFromBaseUrl(scope.baseUrl) ?? defaultHost;
147
+ if (!host) {
148
+ return { skipped: true, reason: `${kind} channel "${name}": no host resolvable` };
149
+ }
150
+ const rules = [{ allow: { method: "POST", path: "/v1/**" } }];
151
+ return {
152
+ networkPolicies: {
153
+ [`writ_${kind}_${name}`]: {
154
+ name: `writ_${kind}_${name}`,
155
+ endpoints: [httpsEndpoint(host, rules)],
156
+ binaries: binariesFor(scope, [DEFAULT_BINARY]),
157
+ },
158
+ },
159
+ anomalies: [
160
+ `${kind} channel "${name}": allowedModels/maxTokensPerCall/rate limits are Fractal-enforced (app-semantic); OpenShell network policy only constrains host+method, not model/token semantics.`,
161
+ ],
162
+ };
163
+ }
164
+ case "github": {
165
+ const allowedRepos = strArr(scope.allowedRepos);
166
+ const allowedOperations = strArr(scope.allowedOperations);
167
+ const anomalies = [];
168
+ const rules = [];
169
+ const unmapped = [];
170
+ for (const op of allowedOperations) {
171
+ const m = GITHUB_OP_MAP[op];
172
+ if (!m) {
173
+ unmapped.push(op);
174
+ continue;
175
+ }
176
+ if (allowedRepos.length > 0) {
177
+ // One rule per (operation, repo). Substitute /** with the repo,
178
+ // PRESERVING the operation suffix (/repos/{repo}/pulls, not /repos/{repo}).
179
+ for (const repo of allowedRepos) {
180
+ rules.push({
181
+ allow: { method: m.method, path: m.path.replace("/**", `/${repo}`) },
182
+ });
183
+ }
184
+ }
185
+ else {
186
+ rules.push({ allow: { method: m.method, path: m.path } });
187
+ }
188
+ }
189
+ if (unmapped.length > 0) {
190
+ anomalies.push(`github channel "${name}": operations ${JSON.stringify(unmapped)} have no L7 method/path mapping; emitted rules cover only the mapped subset.`);
191
+ }
192
+ return {
193
+ networkPolicies: {
194
+ [`writ_github_${name}`]: {
195
+ name: `writ_github_${name}`,
196
+ endpoints: [httpsEndpoint("api.github.com", rules, "read-only")],
197
+ binaries: binariesFor(scope, [DEFAULT_BINARY, "/usr/bin/git", "/usr/local/bin/git"]),
198
+ },
199
+ },
200
+ anomalies,
201
+ };
202
+ }
203
+ case "git": {
204
+ const remotes = strArr(scope.allowRemotes);
205
+ const hosts = [
206
+ ...new Set(remotes.map(hostFromRemote).filter((h) => h !== undefined)),
207
+ ];
208
+ if (hosts.length === 0) {
209
+ return { skipped: true, reason: `git channel "${name}": no allowRemotes hosts resolvable` };
210
+ }
211
+ const endpoints = hosts.map((h) => httpsEndpoint(h, [], "read-write"));
212
+ return {
213
+ networkPolicies: {
214
+ [`writ_git_${name}`]: {
215
+ name: `writ_git_${name}`,
216
+ endpoints,
217
+ binaries: binariesFor(scope, ["/usr/bin/git", "/usr/local/bin/git", DEFAULT_BINARY]),
218
+ },
219
+ },
220
+ anomalies: [
221
+ `git channel "${name}": git can exfiltrate via commit+push. openshell-prover will flag git as a credentialed L7-bypass binary — this is intentional; review the prover finding and ensure allowRemotes is tightly scoped.`,
222
+ ],
223
+ };
224
+ }
225
+ case "package": {
226
+ const registries = strArr(scope.allowedRegistries);
227
+ const hosts = [
228
+ ...new Set(registries.map(hostFromRegistry).filter((h) => h !== undefined)),
229
+ ];
230
+ if (hosts.length === 0) {
231
+ return {
232
+ skipped: true,
233
+ reason: `package channel "${name}": no allowedRegistries hosts resolvable`,
234
+ };
235
+ }
236
+ const endpoints = hosts.map((h) => httpsEndpoint(h, [], "read-write"));
237
+ return {
238
+ networkPolicies: {
239
+ [`writ_package_${name}`]: {
240
+ name: `writ_package_${name}`,
241
+ endpoints,
242
+ binaries: binariesFor(scope, [
243
+ DEFAULT_BINARY,
244
+ "/usr/bin/npm",
245
+ "/usr/bin/pnpm",
246
+ "/usr/bin/yarn",
247
+ "/usr/bin/pip3",
248
+ "/usr/bin/uv",
249
+ ]),
250
+ },
251
+ },
252
+ anomalies: [
253
+ `package channel "${name}": lockfileEnforced/allowedPackages are Fractal-enforced (app-semantic); OpenShell network policy only constrains the registry host.`,
254
+ ],
255
+ };
256
+ }
257
+ default: {
258
+ const dropped = {
259
+ postgres: "read-only via SQL inspection is unsound — use a Postgres read-only role",
260
+ sqlite: "read-only via SQL inspection is unsound — use a read-only DB file/role",
261
+ stripe: "duplicates Stripe's restricted-key + permission-scope model",
262
+ "aws-s3": "duplicates AWS IAM — lean on roles, not JS bucket-scoping",
263
+ sqs: "duplicates AWS IAM",
264
+ docker: "native RBAC; JS re-scoping is a fragile duplicate",
265
+ kubernetes: "native RBAC; JS re-scoping is a fragile duplicate",
266
+ terraform: "native policy model; JS re-scoping is a fragile duplicate",
267
+ vault: "native policy model; JS re-scoping is a fragile duplicate",
268
+ "github-actions": "native RBAC; JS re-scoping is a fragile duplicate",
269
+ browser: "allowedHosts overlaps OpenShell network policy — redundant",
270
+ gitlab: "unaudited — not in initial bridge scope",
271
+ slack: "unaudited — not in initial bridge scope",
272
+ linear: "unaudited — not in initial bridge scope",
273
+ notion: "unaudited — not in initial bridge scope",
274
+ jira: "unaudited — not in initial bridge scope",
275
+ sentry: "unaudited — not in initial bridge scope",
276
+ datadog: "unaudited — not in initial bridge scope",
277
+ resend: "unaudited — not in initial bridge scope",
278
+ pinecone: "unaudited — not in initial bridge scope",
279
+ redactor: "pure data transform — no network/filesystem surface",
280
+ clock: "bounded time access — no network/filesystem surface",
281
+ billing: "internal metrics — no network/filesystem surface",
282
+ webhook: "inbound only — OpenShell governs outbound",
283
+ vitest: "local execution, networkMode: none by default",
284
+ jest: "local execution, networkMode: none by default",
285
+ pytest: "local execution, networkMode: none by default",
286
+ "go-test": "local execution, networkMode: none by default",
287
+ "cargo-test": "local execution, networkMode: none by default",
288
+ test: "local execution, networkMode: none by default",
289
+ build: "local execution — filesystem covered if it writes files",
290
+ process: "local execution — filesystem covered if it writes files",
291
+ search: "local execution — ripgrep over the workspace",
292
+ template: "local string rendering — no network/filesystem surface",
293
+ "gpu-inference": "managed via OpenShell's separate inference route system",
294
+ };
295
+ const reason = dropped[kind] ?? `unknown kind — not in the keeper set; skipped to avoid false assurance`;
296
+ return { skipped: true, reason: `${kind} channel "${name}": ${reason}` };
297
+ }
298
+ }
299
+ }
300
+ function buildFilesystem(readWrite, readOnly) {
301
+ const dedupe = (arr) => [...new Set(arr)].sort();
302
+ return {
303
+ include_workdir: true,
304
+ read_only: dedupe([
305
+ ...readOnly,
306
+ "/usr",
307
+ "/lib",
308
+ "/proc",
309
+ "/dev/urandom",
310
+ "/app",
311
+ "/etc",
312
+ "/var/log",
313
+ ]),
314
+ read_write: dedupe(readWrite),
315
+ };
316
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * fractal-openshell-bridge — deterministic YAML serializer
3
+ *
4
+ * A purpose-built emitter for the PolicyFile shape. Not a general YAML library.
5
+ * Determinism rules:
6
+ * - Object keys emitted in lexicographic order.
7
+ * - Order-irrelevant arrays (paths, hosts, ports) sorted before emission.
8
+ * - Order-relevant arrays (L7 rules, where precedence can matter) preserved
9
+ * in generation order — generation is itself deterministic (sorted inputs).
10
+ * - Strings quoted only when they contain YAML-significant characters; bare
11
+ * otherwise, so the output stays human-readable for manual eyeballing.
12
+ * - No trailing whitespace; LF newlines; 2-space indent.
13
+ *
14
+ * Line length is kept under 120 where practical; scalar values that would
15
+ * exceed it are emitted on their own line.
16
+ */
17
+ /**
18
+ * Serialize a PolicyFile to deterministic YAML. Header comments are prepended
19
+ * (mapping-limitation notes; the caller supplies them so the serializer stays
20
+ * shape-agnostic). The hash is computed over the returned string by the caller.
21
+ */
22
+ export declare function serializePolicy(policy: PolicyFile, headerComments: string[]): string;
23
+ import type { PolicyFile } from "./types";
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ /**
3
+ * fractal-openshell-bridge — deterministic YAML serializer
4
+ *
5
+ * A purpose-built emitter for the PolicyFile shape. Not a general YAML library.
6
+ * Determinism rules:
7
+ * - Object keys emitted in lexicographic order.
8
+ * - Order-irrelevant arrays (paths, hosts, ports) sorted before emission.
9
+ * - Order-relevant arrays (L7 rules, where precedence can matter) preserved
10
+ * in generation order — generation is itself deterministic (sorted inputs).
11
+ * - Strings quoted only when they contain YAML-significant characters; bare
12
+ * otherwise, so the output stays human-readable for manual eyeballing.
13
+ * - No trailing whitespace; LF newlines; 2-space indent.
14
+ *
15
+ * Line length is kept under 120 where practical; scalar values that would
16
+ * exceed it are emitted on their own line.
17
+ */
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.serializePolicy = serializePolicy;
20
+ const INDENT = " ";
21
+ /** True if a string can be emitted bare (unquoted) in YAML. */
22
+ function isBareSafe(s) {
23
+ if (s === "")
24
+ return false;
25
+ // Bare booleans/null/numbers must be quoted to preserve string type.
26
+ if (/^(true|false|null|yes|no|on|off|~)$/i.test(s))
27
+ return false;
28
+ if (/^-?\d+$/.test(s))
29
+ return false;
30
+ // Reject anything with YAML-significant characters.
31
+ if (/[:#{}\[\],&*!|>'"%@`]/.test(s))
32
+ return false;
33
+ if (/^\s|\s$/.test(s))
34
+ return false;
35
+ if (/^[-?]/.test(s))
36
+ return false;
37
+ return true;
38
+ }
39
+ function quoteString(s) {
40
+ // Double-quote; escape backslash and double-quote.
41
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
42
+ }
43
+ function emitScalar(value) {
44
+ if (value === null || value === undefined)
45
+ return "null";
46
+ if (typeof value === "boolean")
47
+ return value ? "true" : "false";
48
+ if (typeof value === "number")
49
+ return Number.isFinite(value) ? String(value) : "null";
50
+ if (typeof value === "string")
51
+ return isBareSafe(value) ? value : quoteString(value);
52
+ // Shouldn't reach here for scalars; stringify defensively.
53
+ return quoteString(String(value));
54
+ }
55
+ /** Sort an array of strings deterministically (order-irrelevant arrays). */
56
+ function sortedStrings(arr) {
57
+ return [...arr].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
58
+ }
59
+ /**
60
+ * Emit a value as YAML at the given indent depth. `topLevelComments` collects
61
+ * header comments (mapping-limitation notes) emitted before the document.
62
+ */
63
+ function emitValue(value, depth) {
64
+ const pad = INDENT.repeat(depth);
65
+ if (Array.isArray(value)) {
66
+ if (value.length === 0)
67
+ return "[]";
68
+ // Arrays of strings → sort for determinism (paths, hosts). Arrays of
69
+ // objects (rules, endpoints) → preserve order; generation sorts inputs.
70
+ const allStrings = value.every((v) => typeof v === "string");
71
+ const items = allStrings ? sortedStrings(value) : value;
72
+ const itemIndent = INDENT.repeat(depth + 1);
73
+ return items
74
+ .map((item) => {
75
+ if (item === null || typeof item !== "object") {
76
+ // Scalar item → inline after the dash: "- /app"
77
+ return `${pad}- ${emitScalar(item)}`;
78
+ }
79
+ // Object/array item → emit at depth+1, then fold the first line up
80
+ // after the dash: "- path: x" or "- allow:\n method: POST".
81
+ const itemStr = emitValue(item, depth + 1);
82
+ return `${pad}- ${itemStr.slice(itemIndent.length)}`;
83
+ })
84
+ .join("\n");
85
+ }
86
+ if (value && typeof value === "object") {
87
+ const obj = value;
88
+ const keys = Object.keys(obj).sort();
89
+ if (keys.length === 0)
90
+ return "{}";
91
+ return keys
92
+ .map((key) => {
93
+ const v = obj[key];
94
+ const vStr = emitValue(v, depth + 1);
95
+ // Inline scalar values: "key: val". Nested: "key:\n ...".
96
+ if (v === null ||
97
+ typeof v !== "object" ||
98
+ (Array.isArray(v) && v.length === 0) ||
99
+ (typeof v === "object" && Object.keys(v).length === 0)) {
100
+ return `${pad}${key}: ${emitScalar(v)}`;
101
+ }
102
+ return `${pad}${key}:\n${vStr}`;
103
+ })
104
+ .join("\n");
105
+ }
106
+ return `${pad}${emitScalar(value)}`;
107
+ }
108
+ /**
109
+ * Serialize a PolicyFile to deterministic YAML. Header comments are prepended
110
+ * (mapping-limitation notes; the caller supplies them so the serializer stays
111
+ * shape-agnostic). The hash is computed over the returned string by the caller.
112
+ */
113
+ function serializePolicy(policy, headerComments) {
114
+ const body = emitValue(policy, 0);
115
+ const header = headerComments.length > 0
116
+ ? headerComments.map((c) => `# ${c}`).join("\n") + "\n\n"
117
+ : "";
118
+ return `${header}${body}\n`;
119
+ }