forgedock 1.0.8 → 1.0.9

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,164 @@
1
+ /**
2
+ * bin/forge-utils.mjs — Shared pure utility functions for ForgeDock.
3
+ *
4
+ * Extracted from bin/hooks/session-start.mjs so they can be unit-tested
5
+ * without importing the hook script (which calls process.exit at module
6
+ * top-level as part of its fail-open contract).
7
+ *
8
+ * Exports:
9
+ * parseForgeYaml(raw) → object (parse a forge.yaml string)
10
+ * sanitizeContextValue(value, max) → string|null (sanitize before injection)
11
+ *
12
+ * Contract guarantees:
13
+ * - Pure: no I/O, no side effects, no global state
14
+ * - Safe: both functions are try/catch-free at the module level; individual
15
+ * helpers never throw
16
+ * - Isolated: imports no external modules
17
+ */
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // YAML parser
21
+ // ---------------------------------------------------------------------------
22
+
23
+ /**
24
+ * Minimal YAML parser for forge.yaml files.
25
+ *
26
+ * Handles:
27
+ * - Top-level scalar values: `key: value` and `key: "quoted value"`
28
+ * - Top-level section headers: `section:` (no value on the same line)
29
+ * - Nested scalars under a section: ` key: value`
30
+ * - Comments (`# ...`) and blank lines are skipped
31
+ * - Both LF and CRLF line endings (split on /\r?\n/)
32
+ *
33
+ * Does NOT handle: arrays, multi-line values, anchors, aliases, or other
34
+ * YAML features. Intended only for the flat key-value structure of forge.yaml.
35
+ *
36
+ * @param {string} raw - Raw string content of a forge.yaml file.
37
+ * @returns {Record<string, string | Record<string, string>>}
38
+ */
39
+ export function parseForgeYaml(raw) {
40
+ const result = {};
41
+ let currentSection = null;
42
+
43
+ for (const line of raw.split(/\r?\n/)) {
44
+ // Skip comments and blank lines
45
+ if (/^\s*#/.test(line) || /^\s*$/.test(line)) continue;
46
+
47
+ // Top-level section header: `section:` (no value on same line)
48
+ const sectionMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*):\s*(?:#.*)?$/);
49
+ if (sectionMatch) {
50
+ currentSection = sectionMatch[1];
51
+ result[currentSection] = {};
52
+ continue;
53
+ }
54
+
55
+ // Top-level scalar: `key: value` (no leading whitespace)
56
+ const topScalarMatch = line.match(
57
+ /^([a-zA-Z_][a-zA-Z0-9_]*):\s+"([^"]*)"(?:\s*#.*)?$|^([a-zA-Z_][a-zA-Z0-9_]*):\s+([^#\n]+?)(?:\s*#.*)?$/,
58
+ );
59
+ if (topScalarMatch && !line.startsWith(" ") && !line.startsWith("\t")) {
60
+ const key = topScalarMatch[1] ?? topScalarMatch[3];
61
+ const value = topScalarMatch[2] ?? topScalarMatch[4]?.trim();
62
+ if (key && value !== undefined) {
63
+ currentSection = null;
64
+ result[key] = value;
65
+ }
66
+ continue;
67
+ }
68
+
69
+ // Nested scalar under current section: ` key: value`
70
+ if (currentSection && /^\s+/.test(line)) {
71
+ const nestedMatch = line.match(
72
+ /^\s+([a-zA-Z_][a-zA-Z0-9_]*):\s+"([^"]*)"(?:\s*#.*)?$|^\s+([a-zA-Z_][a-zA-Z0-9_]*):\s+([^#\n]+?)(?:\s*#.*)?$/,
73
+ );
74
+ if (nestedMatch) {
75
+ const key = nestedMatch[1] ?? nestedMatch[3];
76
+ const value = nestedMatch[2] ?? nestedMatch[4]?.trim();
77
+ if (
78
+ key &&
79
+ value !== undefined &&
80
+ typeof result[currentSection] === "object"
81
+ ) {
82
+ result[currentSection][key] = value;
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ return result;
89
+ }
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Context value sanitizer
93
+ // ---------------------------------------------------------------------------
94
+
95
+ /**
96
+ * Sanitize a forge.yaml value before injecting it into the session context.
97
+ *
98
+ * A forge.yaml file may originate from a cloned repository rather than from
99
+ * ForgeDock's own sanitized writer. This function ensures that untrusted
100
+ * field values cannot inject markdown structural sequences or control
101
+ * characters into the LLM session context.
102
+ *
103
+ * Strips:
104
+ * - Control characters U+0000-U+001F and U+007F-U+009F (including \r, \n, \t)
105
+ * - Leading markdown heading markers (one or more `#` followed by space)
106
+ * - YAML/Markdown horizontal rules (`---` at start of value)
107
+ * - HTML comment delimiters (`<!--` and `-->`)
108
+ * - Triple backtick sequences (fenced code block markers)
109
+ * - Single backticks (inline-code span terminators)
110
+ *
111
+ * Applies stripping in a fixed-point loop: repeats until the string stops
112
+ * changing, so that adjacent fragments that re-form a token after one pass
113
+ * are also stripped.
114
+ *
115
+ * Caps the value to `maxLen` characters after stripping.
116
+ * Trims leading/trailing whitespace.
117
+ *
118
+ * Returns `null` for null/undefined input (preserves existing null guards in
119
+ * the callers so that optional notes are omitted when the field is absent).
120
+ *
121
+ * Never throws — any internal error returns `null` so the hook stays fail-open.
122
+ * <!-- fix: forge#418 -->
123
+ *
124
+ * @param {string|null|undefined} value - Raw value from forge.yaml.
125
+ * @param {number} maxLen - Maximum allowed length after stripping.
126
+ * @returns {string|null}
127
+ */
128
+ export function sanitizeContextValue(value, maxLen) {
129
+ try {
130
+ if (value == null) return null;
131
+ // eslint-disable-next-line no-control-regex
132
+ let stripped = String(value)
133
+ // Strip control characters (C0 block U+0000-U+001F, DEL U+007F, C1 block U+0080-U+009F)
134
+ .replace(/[\x00-\x1F\x7F-\x9F]/g, "");
135
+ // Strip injection-capable token sequences until a fixed point. A single
136
+ // pass can re-form a token from adjacent fragments (e.g. "<!<!---->--"
137
+ // leaves "<!--"), and HTML parsers accept "--!>" as a comment close in
138
+ // addition to "-->". Each pass strictly shrinks the string, so the loop
139
+ // terminates.
140
+ let prev;
141
+ do {
142
+ prev = stripped;
143
+ stripped = stripped
144
+ // Strip leading markdown headings (e.g. "## Title" → "Title")
145
+ .replace(/^#+\s+/g, "")
146
+ // Strip horizontal rule patterns at start of value
147
+ .replace(/^---+/g, "")
148
+ // Strip HTML comment delimiters (open, and both close forms)
149
+ .replace(/<!--/g, "")
150
+ .replace(/--!?>/g, "")
151
+ // Strip triple backtick fenced code block markers
152
+ .replace(/`{3}/g, "")
153
+ // Strip single backticks — values are interpolated inside inline-code
154
+ // spans (e.g. `${stagingBranch}`), so any backtick in the value would
155
+ // terminate the span prematurely and corrupt the rendered context.
156
+ // <!-- fix: forge#443 -->
157
+ .replace(/`/g, "");
158
+ } while (stripped !== prev);
159
+ const str = stripped.trim().slice(0, maxLen);
160
+ return str.length > 0 ? str : null;
161
+ } catch {
162
+ return null;
163
+ }
164
+ }