@forwardimpact/libharness 0.1.22 → 1.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.
- package/LICENSE +21 -201
- package/README.md +196 -80
- package/bin/fit-benchmark.js +44 -0
- package/bin/fit-harness.js +358 -0
- package/bin/fit-selfedit.js +165 -0
- package/bin/fit-trace.js +510 -0
- package/package.json +41 -11
- package/src/agent-runner.js +256 -0
- package/src/benchmark/apm-installer.js +207 -0
- package/src/benchmark/env-loader.js +158 -0
- package/src/benchmark/hook-env.js +40 -0
- package/src/benchmark/invariants.js +141 -0
- package/src/benchmark/judge.js +187 -0
- package/src/benchmark/npm-installer.js +87 -0
- package/src/benchmark/report.js +604 -0
- package/src/benchmark/result.js +127 -0
- package/src/benchmark/runner.js +688 -0
- package/src/benchmark/scheduler.js +78 -0
- package/src/benchmark/task-family.js +260 -0
- package/src/benchmark/workdir.js +344 -0
- package/src/commands/assert.js +153 -0
- package/src/commands/benchmark-definition.js +175 -0
- package/src/commands/benchmark-invariants.js +73 -0
- package/src/commands/benchmark-report.js +51 -0
- package/src/commands/benchmark-run.js +175 -0
- package/src/commands/by-discussion.js +94 -0
- package/src/commands/callback.js +119 -0
- package/src/commands/discuss.js +132 -0
- package/src/commands/facilitate.js +123 -0
- package/src/commands/output.js +36 -0
- package/src/commands/run.js +152 -0
- package/src/commands/supervise.js +136 -0
- package/src/commands/task-input.js +54 -0
- package/src/commands/tee.js +53 -0
- package/src/commands/trace.js +630 -0
- package/src/commands/work-tracker.js +35 -0
- package/src/cost.js +79 -0
- package/src/discuss-tools.js +173 -0
- package/src/discusser.js +394 -0
- package/src/events/github.js +161 -0
- package/src/facilitator.js +205 -0
- package/src/inbox-poller.js +81 -0
- package/src/index.js +72 -2
- package/src/judge.js +210 -0
- package/src/message-bus.js +118 -0
- package/src/orchestration-loop.js +330 -0
- package/src/orchestration-toolkit.js +441 -0
- package/src/orchestrator-helpers.js +23 -0
- package/src/profile-prompt.js +266 -0
- package/src/redaction.js +253 -0
- package/src/render/line-renderer.js +54 -0
- package/src/render/orchestrator-filter.js +19 -0
- package/src/render/palette.js +63 -0
- package/src/render/tool-hints.js +154 -0
- package/src/render/turn-renderer.js +96 -0
- package/src/reply-emitter.js +47 -0
- package/src/sequence-counter.js +21 -0
- package/src/signature-filter.js +27 -0
- package/src/supervisor.js +236 -0
- package/src/tee-writer.js +150 -0
- package/src/trace-collector.js +444 -0
- package/src/trace-github.js +473 -0
- package/src/trace-multi.js +101 -0
- package/src/trace-query.js +748 -0
- package/src/trace-render.js +211 -0
- package/src/trace-usage.js +249 -0
- package/src/fixture/assertions.js +0 -42
- package/src/fixture/cache.js +0 -50
- package/src/fixture/eval.js +0 -146
- package/src/fixture/index.js +0 -9
- package/src/fixture/pathway.js +0 -451
- package/src/fixture/services.js +0 -56
- package/src/mock/clients.js +0 -135
- package/src/mock/config.js +0 -45
- package/src/mock/data.js +0 -46
- package/src/mock/fs.js +0 -111
- package/src/mock/grpc.js +0 -94
- package/src/mock/http.js +0 -60
- package/src/mock/index.js +0 -36
- package/src/mock/infra.js +0 -219
- package/src/mock/logger.js +0 -42
- package/src/mock/observer.js +0 -74
- package/src/mock/resource-index.js +0 -95
- package/src/mock/service-callbacks.js +0 -39
- package/src/mock/services.js +0 -79
- package/src/mock/spy.js +0 -44
- package/src/mock/storage.js +0 -118
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System prompt composition for agent runners.
|
|
3
|
+
*
|
|
4
|
+
* libharness assembles every agent system prompt from up to two parallel,
|
|
5
|
+
* sibling-tagged sections (see COALIGNED.md § L0):
|
|
6
|
+
*
|
|
7
|
+
* <agent_profile>
|
|
8
|
+
* …persona body…
|
|
9
|
+
* </agent_profile>
|
|
10
|
+
*
|
|
11
|
+
* <session_protocol>
|
|
12
|
+
* …orchestration mechanics, then any amendment…
|
|
13
|
+
* </session_protocol>
|
|
14
|
+
*
|
|
15
|
+
* The two tags are siblings joined by a blank line — neither nests inside
|
|
16
|
+
* the other. A section appears only when its content is present. The tag
|
|
17
|
+
* convention lives entirely here: profile `.md` files and trailer constants
|
|
18
|
+
* carry no tags.
|
|
19
|
+
*
|
|
20
|
+
* The `<session_protocol>` body is assembled from up to three fragments, in
|
|
21
|
+
* order of decreasing generality:
|
|
22
|
+
*
|
|
23
|
+
* 1. the role-invariant orchestration trailer (libharness-owned);
|
|
24
|
+
* 2. the profile's own hoisted `## Session Protocol` section, if present;
|
|
25
|
+
* 3. a run-specific amendment, if supplied.
|
|
26
|
+
*
|
|
27
|
+
* Fragment 2 is the convention-based hoist: a profile may carry a level-2
|
|
28
|
+
* `## Session Protocol` markdown heading whose body is the role's work
|
|
29
|
+
* routine. When present, that section is lifted out of `<agent_profile>` and
|
|
30
|
+
* folded into `<session_protocol>` next to the orchestration mechanics, so
|
|
31
|
+
* the harness comms protocol and the role's work routine read as one
|
|
32
|
+
* coherent block. The heading line itself is dropped — the tag already names
|
|
33
|
+
* the section. Profiles with no such heading are unaffected (the entire body
|
|
34
|
+
* stays in `<agent_profile>`).
|
|
35
|
+
*
|
|
36
|
+
* Helpers:
|
|
37
|
+
*
|
|
38
|
+
* - `composeProfilePrompt(name, opts)` — profile + `claude_code` preset.
|
|
39
|
+
* Used by agent participants that need the full Claude Code tool surface.
|
|
40
|
+
*
|
|
41
|
+
* - `composeLeadPrompt(opts)` — plain string, no preset. Used by lead
|
|
42
|
+
* roles (supervisor, facilitator, discuss lead) that should only see
|
|
43
|
+
* the orchestration instructions and optionally a profile body.
|
|
44
|
+
*
|
|
45
|
+
* - `composeSystemPrompt(opts)` — unified entry point. Threads `amend` into
|
|
46
|
+
* the protocol section as the run-specific fragment, then delegates to one
|
|
47
|
+
* of the above based on `opts.role`.
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { join } from "node:path";
|
|
51
|
+
|
|
52
|
+
/** Sibling section tags. Neither nests inside the other. */
|
|
53
|
+
const AGENT_PROFILE_TAG = "agent_profile";
|
|
54
|
+
const SESSION_PROTOCOL_TAG = "session_protocol";
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A level-2 heading that names the profile's hoisted session-protocol
|
|
58
|
+
* section. Case-insensitive, tolerant of trailing whitespace, but the level
|
|
59
|
+
* is fixed at two `#` so a `### Session Protocol` subsection does not trip
|
|
60
|
+
* the hoist.
|
|
61
|
+
*/
|
|
62
|
+
const SESSION_PROTOCOL_HEADING = /^##[ \t]+session protocol[ \t]*$/i;
|
|
63
|
+
|
|
64
|
+
/** A level-1 or level-2 heading — the boundary that ends a hoisted section. */
|
|
65
|
+
const SECTION_BOUNDARY = /^#{1,2}[ \t]+\S/;
|
|
66
|
+
|
|
67
|
+
/** Wrap content in a semantic section tag, each on its own line. */
|
|
68
|
+
function wrapSection(tag, content) {
|
|
69
|
+
return `<${tag}>\n${content}\n</${tag}>`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Assemble the parallel `<agent_profile>` / `<session_protocol>` sections.
|
|
74
|
+
* The profile section is emitted only when `body` is non-empty. The protocol
|
|
75
|
+
* section is built by joining its fragments (in the order given) with a
|
|
76
|
+
* blank-line separator, dropping any that are empty, and is emitted only
|
|
77
|
+
* when at least one fragment survives. The two tags are siblings joined by a
|
|
78
|
+
* blank line and never nest.
|
|
79
|
+
*
|
|
80
|
+
* @param {object} parts
|
|
81
|
+
* @param {string} [parts.body] - Profile body, frontmatter-stripped and with
|
|
82
|
+
* any `## Session Protocol` section already hoisted out.
|
|
83
|
+
* @param {Array<string | undefined>} [parts.protocolParts] - Ordered session
|
|
84
|
+
* protocol fragments: trailer, hoisted profile section, run amendment.
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
function assembleSections({ body, protocolParts = [] }) {
|
|
88
|
+
const sections = [];
|
|
89
|
+
if (body) sections.push(wrapSection(AGENT_PROFILE_TAG, body));
|
|
90
|
+
const protocol = protocolParts.filter(Boolean).join("\n\n");
|
|
91
|
+
if (protocol) sections.push(wrapSection(SESSION_PROTOCOL_TAG, protocol));
|
|
92
|
+
return sections.join("\n\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Split a frontmatter-stripped profile body into its persona and an optional
|
|
97
|
+
* hoisted `## Session Protocol` section. The section runs from its heading to
|
|
98
|
+
* the next level-1/level-2 heading (or end of body); the heading line is
|
|
99
|
+
* dropped. Anything before and after the section is rejoined into `persona`.
|
|
100
|
+
* When the body carries no `## Session Protocol` heading, the whole body is
|
|
101
|
+
* returned as `persona` and `protocol` is `undefined`.
|
|
102
|
+
*
|
|
103
|
+
* @param {string} body - Frontmatter-stripped, trimmed profile body.
|
|
104
|
+
* @returns {{ persona: string, protocol: string | undefined }}
|
|
105
|
+
*/
|
|
106
|
+
function splitSessionProtocol(body) {
|
|
107
|
+
const lines = body.split("\n");
|
|
108
|
+
const start = lines.findIndex((line) => SESSION_PROTOCOL_HEADING.test(line));
|
|
109
|
+
if (start === -1) return { persona: body, protocol: undefined };
|
|
110
|
+
|
|
111
|
+
let end = lines.length;
|
|
112
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
113
|
+
if (SECTION_BOUNDARY.test(lines[i])) {
|
|
114
|
+
end = i;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const protocol = lines
|
|
120
|
+
.slice(start + 1, end)
|
|
121
|
+
.join("\n")
|
|
122
|
+
.trim();
|
|
123
|
+
const before = lines.slice(0, start).join("\n").trim();
|
|
124
|
+
const after = lines.slice(end).join("\n").trim();
|
|
125
|
+
const persona = [before, after].filter(Boolean).join("\n\n");
|
|
126
|
+
return { persona, protocol: protocol || undefined };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Read a profile `.md`, strip its frontmatter, and split off any hoisted
|
|
131
|
+
* `## Session Protocol` section. Reads synchronously off the injected
|
|
132
|
+
* `runtime.fsSync` surface — this composer runs inside the synchronous
|
|
133
|
+
* SDK-option builders of the supervisor / facilitator / discusser / judge
|
|
134
|
+
* factories, so it cannot go async without an unbounded cascade.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} name - Profile basename (no `.md` suffix)
|
|
137
|
+
* @param {string} profilesDir - Directory containing `<name>.md`
|
|
138
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
|
|
139
|
+
* @returns {{ persona: string, protocol: string | undefined }}
|
|
140
|
+
*/
|
|
141
|
+
function readProfileSections(name, profilesDir, runtime) {
|
|
142
|
+
const path = join(profilesDir, `${name}.md`);
|
|
143
|
+
const raw = runtime.fsSync.readFileSync(path, "utf8");
|
|
144
|
+
return splitSessionProtocol(stripFrontmatter(raw).trim());
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Compose a `claude_code`-preset system prompt from a profile file. The
|
|
149
|
+
* persona is wrapped in `<agent_profile>`; the protocol trailer, the
|
|
150
|
+
* profile's hoisted `## Session Protocol` section, and any amendment are
|
|
151
|
+
* joined (in that order) into a sibling `<session_protocol>`.
|
|
152
|
+
*
|
|
153
|
+
* @param {string} name - Profile basename (no `.md` suffix)
|
|
154
|
+
* @param {object} opts
|
|
155
|
+
* @param {string} opts.profilesDir - Directory containing `<name>.md`
|
|
156
|
+
* @param {string} [opts.trailer] - Session protocol orchestration mechanics,
|
|
157
|
+
* the first fragment of the `<session_protocol>` section.
|
|
158
|
+
* @param {string} [opts.amend] - Run-specific amendment, the last fragment of
|
|
159
|
+
* the `<session_protocol>` section.
|
|
160
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} opts.runtime - Ambient collaborators; uses `fsSync.readFileSync`.
|
|
161
|
+
* @returns {{type: "preset", preset: "claude_code", append: string}}
|
|
162
|
+
*/
|
|
163
|
+
export function composeProfilePrompt(
|
|
164
|
+
name,
|
|
165
|
+
{ profilesDir, trailer, amend, runtime },
|
|
166
|
+
) {
|
|
167
|
+
const { persona, protocol } = readProfileSections(name, profilesDir, runtime);
|
|
168
|
+
return {
|
|
169
|
+
type: "preset",
|
|
170
|
+
preset: "claude_code",
|
|
171
|
+
append: assembleSections({
|
|
172
|
+
body: persona,
|
|
173
|
+
protocolParts: [trailer, protocol, amend],
|
|
174
|
+
}),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Compose a plain-string system prompt for a lead role (no Claude Code
|
|
180
|
+
* preset). The protocol trailer, an optional profile's hoisted
|
|
181
|
+
* `## Session Protocol` section, and any amendment are joined into
|
|
182
|
+
* `<session_protocol>`; an optional persona is wrapped in a sibling
|
|
183
|
+
* `<agent_profile>` before it.
|
|
184
|
+
*
|
|
185
|
+
* @param {object} opts
|
|
186
|
+
* @param {string} [opts.profile] - Profile basename (no `.md` suffix)
|
|
187
|
+
* @param {string} [opts.profilesDir] - Directory containing profile files
|
|
188
|
+
* @param {string} opts.trailer - Session protocol (orchestration instructions)
|
|
189
|
+
* @param {string} [opts.amend] - Run-specific amendment, the last fragment of
|
|
190
|
+
* the `<session_protocol>` section.
|
|
191
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} opts.runtime - Ambient collaborators; uses `fsSync.readFileSync`.
|
|
192
|
+
* @returns {string}
|
|
193
|
+
*/
|
|
194
|
+
export function composeLeadPrompt({
|
|
195
|
+
profile,
|
|
196
|
+
profilesDir,
|
|
197
|
+
trailer,
|
|
198
|
+
amend,
|
|
199
|
+
runtime,
|
|
200
|
+
}) {
|
|
201
|
+
if (!trailer) throw new Error("trailer is required");
|
|
202
|
+
const { persona, protocol } = profile
|
|
203
|
+
? readProfileSections(profile, profilesDir, runtime)
|
|
204
|
+
: { persona: undefined, protocol: undefined };
|
|
205
|
+
return assembleSections({
|
|
206
|
+
body: persona,
|
|
207
|
+
protocolParts: [trailer, protocol, amend],
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Unified entry point for composing system prompts. Threads an optional
|
|
213
|
+
* amendment through as the run-specific fragment of `<session_protocol>`
|
|
214
|
+
* (after the trailer and any hoisted profile section), then delegates by
|
|
215
|
+
* role.
|
|
216
|
+
*
|
|
217
|
+
* @param {object} opts
|
|
218
|
+
* @param {"lead"|"agent"} opts.role - `"lead"` produces a plain string;
|
|
219
|
+
* `"agent"` produces a `claude_code` preset object.
|
|
220
|
+
* @param {string} [opts.profile] - Profile basename
|
|
221
|
+
* @param {string} [opts.profilesDir]
|
|
222
|
+
* @param {string} opts.trailer - Session protocol (orchestration instructions)
|
|
223
|
+
* @param {string} [opts.amend] - Caller-supplied amendment, the last fragment
|
|
224
|
+
* inside `<session_protocol>`, joined with a blank-line separator.
|
|
225
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} opts.runtime - Ambient collaborators; uses `fsSync.readFileSync`.
|
|
226
|
+
* @returns {string | {type: "preset", preset: "claude_code", append: string}}
|
|
227
|
+
*/
|
|
228
|
+
export function composeSystemPrompt({
|
|
229
|
+
role,
|
|
230
|
+
profile,
|
|
231
|
+
profilesDir,
|
|
232
|
+
trailer,
|
|
233
|
+
amend,
|
|
234
|
+
runtime,
|
|
235
|
+
}) {
|
|
236
|
+
if (!trailer) throw new Error("trailer is required");
|
|
237
|
+
if (role === "lead") {
|
|
238
|
+
return composeLeadPrompt({ profile, profilesDir, trailer, amend, runtime });
|
|
239
|
+
}
|
|
240
|
+
if (profile) {
|
|
241
|
+
return composeProfilePrompt(profile, {
|
|
242
|
+
profilesDir,
|
|
243
|
+
trailer,
|
|
244
|
+
amend,
|
|
245
|
+
runtime,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
type: "preset",
|
|
250
|
+
preset: "claude_code",
|
|
251
|
+
append: assembleSections({ protocolParts: [trailer, amend] }),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Strip a leading YAML frontmatter fence (`---\n…\n---\n`) from a markdown
|
|
257
|
+
* string. Returns the input unchanged when no frontmatter is present.
|
|
258
|
+
* @param {string} raw
|
|
259
|
+
* @returns {string}
|
|
260
|
+
*/
|
|
261
|
+
function stripFrontmatter(raw) {
|
|
262
|
+
if (!raw.startsWith("---\n")) return raw;
|
|
263
|
+
const end = raw.indexOf("\n---\n", 4);
|
|
264
|
+
if (end === -1) return raw;
|
|
265
|
+
return raw.slice(end + 5);
|
|
266
|
+
}
|
package/src/redaction.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redactor — replaces secrets in JSON-serialisable values before they reach
|
|
3
|
+
* the trace artifact. Composes two layers: an env-var value allowlist and a
|
|
4
|
+
* set of credential-shape regexes. Both run on every primitive string.
|
|
5
|
+
*
|
|
6
|
+
* Coverage includes encoded credential forms, not only raw bytes: the env
|
|
7
|
+
* layer matches each allowlisted secret both raw and in its **standard
|
|
8
|
+
* base64** form at any byte offset within the encoded plaintext, and the
|
|
9
|
+
* pattern layer covers the git `extraheader` basic-auth wrapper. Boundary:
|
|
10
|
+
* **standard base64 only** — URL-safe base64, hex, and percent-encoding are
|
|
11
|
+
* not covered — and the **trace-write sink only**; content an agent authors
|
|
12
|
+
* into a wiki commit is never passed through this redactor.
|
|
13
|
+
*
|
|
14
|
+
* Stateless after construction: `env` is captured once so in-process
|
|
15
|
+
* `process.env` writes (e.g. agent-runner.js LIBHARNESS_SKILL, commands/run.js
|
|
16
|
+
* LIBHARNESS_AGENT_PROFILE) cannot smuggle a value past the redactor.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const DEFAULT_ENV_ALLOWLIST = Object.freeze([
|
|
20
|
+
"ANTHROPIC_API_KEY",
|
|
21
|
+
"AWS_ACCESS_KEY_ID",
|
|
22
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
23
|
+
"DATABASE_PASSWORD",
|
|
24
|
+
"GH_TOKEN",
|
|
25
|
+
"GITHUB_TOKEN",
|
|
26
|
+
"JWT_SECRET",
|
|
27
|
+
"MCP_TOKEN",
|
|
28
|
+
"MICROSOFT_APP_ID",
|
|
29
|
+
"MICROSOFT_APP_PASSWORD",
|
|
30
|
+
"MICROSOFT_APP_TENANT_ID",
|
|
31
|
+
"PRODUCT_LANDMARK_TOKEN",
|
|
32
|
+
"SERVICE_SECRET",
|
|
33
|
+
"SUPABASE_ANON_KEY",
|
|
34
|
+
"SUPABASE_SERVICE_ROLE_KEY",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
// Anchored prefixes per
|
|
38
|
+
// https://github.blog/security/application-security/behind-githubs-new-authentication-token-formats/
|
|
39
|
+
// Anthropic prefix is heuristic — the env-allowlist layer is the primary
|
|
40
|
+
// defence for Anthropic keys.
|
|
41
|
+
export const DEFAULT_PATTERNS = Object.freeze([
|
|
42
|
+
{ kind: "anthropic", regex: /sk-ant-[A-Za-z0-9_-]{80,}/g },
|
|
43
|
+
{ kind: "gh-pat", regex: /\bghp_[A-Za-z0-9]{36}\b/g },
|
|
44
|
+
{ kind: "gh-installation", regex: /\bghs_[A-Za-z0-9]{36}\b/g },
|
|
45
|
+
{ kind: "gh-oauth", regex: /\bgho_[A-Za-z0-9]{36}\b/g },
|
|
46
|
+
{ kind: "gh-fine-grained", regex: /\bgithub_pat_[A-Za-z0-9_]{82}\b/g },
|
|
47
|
+
// git persists HTTP basic-auth credentials base64-encoded in
|
|
48
|
+
// `http.<url>.extraheader` as `AUTHORIZATION: basic <b64>` where the
|
|
49
|
+
// plaintext is `x-access-token:<token>` (actions/checkout form) — a shape
|
|
50
|
+
// the raw-byte layers above cannot see. The plaintext prefix is 15 bytes
|
|
51
|
+
// — five whole base64 triplets — so every encoding starts with the same
|
|
52
|
+
// 20 chars no matter which token follows.
|
|
53
|
+
{
|
|
54
|
+
kind: "gh-b64-basic-credential",
|
|
55
|
+
regex: /\beC1hY2Nlc3MtdG9rZW46[A-Za-z0-9+/]{8,}={0,2}/g,
|
|
56
|
+
},
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
const ENV_PLACEHOLDER = (name) => `[REDACTED:env:${name}]`;
|
|
60
|
+
const PATTERN_PLACEHOLDER = (kind) => `[REDACTED:pattern:${kind}]`;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Minimum secret byte length for encoded-form matching. At 9 bytes the
|
|
64
|
+
* shortest offset core is exactly 8 chars; below 9 it drops under 8 — too
|
|
65
|
+
* short to be a sound needle against ordinary base64 trace content (margin of
|
|
66
|
+
* safety, false positives). Every DEFAULT_ENV_ALLOWLIST value (token, key,
|
|
67
|
+
* password) far exceeds it.
|
|
68
|
+
*/
|
|
69
|
+
const MIN_ENCODED_SECRET_BYTES = 9;
|
|
70
|
+
|
|
71
|
+
// Leading base64 chars contaminated by the k filler bytes, per alignment.
|
|
72
|
+
const ENCODED_LEAD_STRIP = [0, 2, 3];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The three offset-invariant standard-base64 core substrings of `secret`, one
|
|
76
|
+
* per byte alignment (k = 0/1/2). base64 maps disjoint 3-byte groups to 4 chars
|
|
77
|
+
* independently, so the chars covering a secret's interior groups depend only
|
|
78
|
+
* on the secret's bytes — never on the bytes surrounding it. Only the partial
|
|
79
|
+
* groups at each edge are neighbour-dependent; stripping them leaves a core
|
|
80
|
+
* that appears in the base64 of any plaintext placing `secret` at that
|
|
81
|
+
* alignment. Padding lives only in the final partial group, which is stripped,
|
|
82
|
+
* so each core is padding-free and one needle matches padded and unpadded
|
|
83
|
+
* haystack content. Returns [] below MIN_ENCODED_SECRET_BYTES.
|
|
84
|
+
* @param {string} secret
|
|
85
|
+
* @returns {string[]}
|
|
86
|
+
*/
|
|
87
|
+
function encodedNeedles(secret) {
|
|
88
|
+
if (Buffer.byteLength(secret, "utf8") < MIN_ENCODED_SECRET_BYTES) return [];
|
|
89
|
+
const needles = [];
|
|
90
|
+
for (let k = 0; k < 3; k++) {
|
|
91
|
+
const enc = Buffer.from("\0".repeat(k) + secret, "utf8")
|
|
92
|
+
.toString("base64")
|
|
93
|
+
.replace(/=+$/, "");
|
|
94
|
+
needles.push(enc.slice(ENCODED_LEAD_STRIP[k], enc.length - 4));
|
|
95
|
+
}
|
|
96
|
+
return needles;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build a frozen { name → { secret, needles } } snapshot of the requested env
|
|
101
|
+
* vars. Empty strings are skipped — a leaked empty env var would otherwise
|
|
102
|
+
* cause every empty string in the trace to be replaced. `needles` are the
|
|
103
|
+
* precomputed standard-base64 cores (empty for sub-floor secrets).
|
|
104
|
+
*/
|
|
105
|
+
function snapshotEnv(env, allowlist) {
|
|
106
|
+
const snap = {};
|
|
107
|
+
for (const name of allowlist) {
|
|
108
|
+
const v = env[name];
|
|
109
|
+
if (typeof v === "string" && v.length > 0) {
|
|
110
|
+
snap[name] = { secret: v, needles: encodedNeedles(v) };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return Object.freeze(snap);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Recursively walk and redact a JSON-serialisable value in place-free style. */
|
|
117
|
+
function walk(value, redactString) {
|
|
118
|
+
if (typeof value === "string") return redactString(value);
|
|
119
|
+
if (Array.isArray(value)) return value.map((v) => walk(v, redactString));
|
|
120
|
+
if (value && typeof value === "object") {
|
|
121
|
+
const out = {};
|
|
122
|
+
for (const k of Object.keys(value)) out[k] = walk(value[k], redactString);
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
return value;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Stateless secret redactor — composes env-allowlist and pattern layers. */
|
|
129
|
+
export class Redactor {
|
|
130
|
+
/**
|
|
131
|
+
* @param {object} deps
|
|
132
|
+
* @param {Readonly<Record<string, {secret: string, needles: string[]}>>} deps.envSnapshot - Frozen { name → { secret, needles } } map captured at construction time; `needles` are the precomputed standard-base64 cores of `secret`.
|
|
133
|
+
* @param {ReadonlyArray<{kind: string, regex: RegExp}>} deps.patterns - Credential-shape regexes; each match becomes `[REDACTED:pattern:KIND]`.
|
|
134
|
+
* @param {boolean} deps.enabled - When false, `redactValue` returns its input by reference.
|
|
135
|
+
*/
|
|
136
|
+
constructor({ envSnapshot, patterns, enabled }) {
|
|
137
|
+
this.envSnapshot = envSnapshot;
|
|
138
|
+
this.patterns = patterns;
|
|
139
|
+
this.enabled = enabled;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Redact any JSON-serialisable value by deep-walking and replacing secrets
|
|
144
|
+
* in every primitive string. Identity on the input when disabled.
|
|
145
|
+
* @param {unknown} value
|
|
146
|
+
* @returns {unknown}
|
|
147
|
+
*/
|
|
148
|
+
redactValue(value) {
|
|
149
|
+
if (!this.enabled) return value;
|
|
150
|
+
return walk(value, (s) => this.#redactString(s));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Apply the env-allowlist and pattern layers to a single string.
|
|
155
|
+
* @param {string} s
|
|
156
|
+
* @returns {string}
|
|
157
|
+
*/
|
|
158
|
+
#redactString(s) {
|
|
159
|
+
let out = s;
|
|
160
|
+
for (const [name, { secret, needles }] of Object.entries(
|
|
161
|
+
this.envSnapshot,
|
|
162
|
+
)) {
|
|
163
|
+
if (out.includes(secret)) {
|
|
164
|
+
out = out.split(secret).join(ENV_PLACEHOLDER(name));
|
|
165
|
+
}
|
|
166
|
+
// Standard-base64 form at any byte offset. Order among the three needles
|
|
167
|
+
// is irrelevant: once a region is replaced by the placeholder (which
|
|
168
|
+
// shares no base64 run with any needle) those bytes are gone, so a later
|
|
169
|
+
// needle cannot re-match them. The floor keeps every needle ≥ 8 chars.
|
|
170
|
+
for (const needle of needles) {
|
|
171
|
+
if (out.includes(needle)) {
|
|
172
|
+
out = out.split(needle).join(ENV_PLACEHOLDER(name));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const { kind, regex } of this.patterns) {
|
|
177
|
+
out = out.replace(regex, PATTERN_PLACEHOLDER(kind));
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Build a redactor. Reads `LIBHARNESS_REDACTION_DISABLED` and
|
|
185
|
+
* `LIBHARNESS_REDACTION_ENV_VARS` from the supplied env. The env and the stderr
|
|
186
|
+
* sink are sourced from an injected `runtime` (`runtime.proc.env` /
|
|
187
|
+
* `runtime.proc.stderr`); when no runtime is supplied a default one is
|
|
188
|
+
* constructed so existing callers keep working. An explicit `opts.env`
|
|
189
|
+
* override still wins for the snapshot. Fires a one-shot stderr warning when
|
|
190
|
+
* constructed disabled — bypass via `createNoopRedactor()` for silent
|
|
191
|
+
* fixtures.
|
|
192
|
+
* @param {object} [opts]
|
|
193
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} [opts.runtime] - Ambient collaborators; `proc.env`/`proc.stderr` are used.
|
|
194
|
+
* @param {Record<string, string|undefined>} [opts.env] - Environment to snapshot. Defaults to `runtime.proc.env`.
|
|
195
|
+
* @param {string[]} [opts.allowlist] - Override the env-var name list. Defaults to `DEFAULT_ENV_ALLOWLIST` or the parsed `LIBHARNESS_REDACTION_ENV_VARS` value.
|
|
196
|
+
* @param {ReadonlyArray<{kind: string, regex: RegExp}>} [opts.patterns] - Credential-shape regexes. Defaults to `DEFAULT_PATTERNS`.
|
|
197
|
+
* @param {boolean} [opts.enabled] - Force enabled/disabled; bypasses `LIBHARNESS_REDACTION_DISABLED`.
|
|
198
|
+
* @returns {Redactor}
|
|
199
|
+
*/
|
|
200
|
+
export function createRedactor({
|
|
201
|
+
runtime,
|
|
202
|
+
env,
|
|
203
|
+
allowlist,
|
|
204
|
+
patterns = DEFAULT_PATTERNS,
|
|
205
|
+
enabled,
|
|
206
|
+
} = {}) {
|
|
207
|
+
if (!runtime) throw new Error("runtime is required");
|
|
208
|
+
const proc = runtime.proc;
|
|
209
|
+
const resolvedEnv = env ?? proc.env;
|
|
210
|
+
const envDisabled = resolvedEnv.LIBHARNESS_REDACTION_DISABLED === "1";
|
|
211
|
+
const resolvedEnabled = enabled ?? !envDisabled;
|
|
212
|
+
const resolvedAllowlist = allowlist ?? resolveAllowlistFromEnv(resolvedEnv);
|
|
213
|
+
const envSnapshot = resolvedEnabled
|
|
214
|
+
? snapshotEnv(resolvedEnv, resolvedAllowlist)
|
|
215
|
+
: Object.freeze({});
|
|
216
|
+
if (!resolvedEnabled) {
|
|
217
|
+
proc.stderr.write(
|
|
218
|
+
"libharness: trace redaction DISABLED via LIBHARNESS_REDACTION_DISABLED — secrets may appear in trace artifact\n",
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
return new Redactor({ envSnapshot, patterns, enabled: resolvedEnabled });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Parse `LIBHARNESS_REDACTION_ENV_VARS` into a trimmed, non-empty name list.
|
|
226
|
+
* Falls back to `DEFAULT_ENV_ALLOWLIST` when unset or empty.
|
|
227
|
+
* @param {Record<string, string|undefined>} env
|
|
228
|
+
* @returns {string[]}
|
|
229
|
+
*/
|
|
230
|
+
function resolveAllowlistFromEnv(env) {
|
|
231
|
+
const override = env.LIBHARNESS_REDACTION_ENV_VARS;
|
|
232
|
+
if (typeof override !== "string" || override.length === 0) {
|
|
233
|
+
return DEFAULT_ENV_ALLOWLIST;
|
|
234
|
+
}
|
|
235
|
+
return override
|
|
236
|
+
.split(",")
|
|
237
|
+
.map((s) => s.trim())
|
|
238
|
+
.filter(Boolean);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Build a disabled redactor whose `redactValue` is the identity function.
|
|
243
|
+
* Test-fixture form — bypasses `createRedactor` so no stderr warning
|
|
244
|
+
* fires regardless of env state.
|
|
245
|
+
* @returns {Redactor}
|
|
246
|
+
*/
|
|
247
|
+
export function createNoopRedactor() {
|
|
248
|
+
return new Redactor({
|
|
249
|
+
envSnapshot: Object.freeze({}),
|
|
250
|
+
patterns: [],
|
|
251
|
+
enabled: false,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Line renderer — composes prefix + color + body + reset into a single
|
|
3
|
+
* terminal line. Pure; no side effects.
|
|
4
|
+
*
|
|
5
|
+
* Every renderer returns a `\n`-terminated string:
|
|
6
|
+
* <source>: <ESC><color><body><RESET>\n
|
|
7
|
+
*
|
|
8
|
+
* The `<source>: ` prefix lives outside the color escape so grep and
|
|
9
|
+
* color-stripping terminals preserve the participant tag. Colons separate
|
|
10
|
+
* the source label and the kind label (`Bash:`, `Result:`, `Error:`) for a
|
|
11
|
+
* tighter line on narrow viewports without losing structure.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { colorForSource, ERROR_COLOR, RESET } from "./palette.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {string|null} source
|
|
18
|
+
* @param {boolean} withPrefix
|
|
19
|
+
* @returns {string}
|
|
20
|
+
*/
|
|
21
|
+
function prefix(source, withPrefix) {
|
|
22
|
+
if (!withPrefix || !source) return "";
|
|
23
|
+
return `${source}: `;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {{source: string|null, text: string, withPrefix: boolean}} args
|
|
28
|
+
* @returns {string}
|
|
29
|
+
*/
|
|
30
|
+
export function renderTextLine({ source, text, withPrefix }) {
|
|
31
|
+
const color = colorForSource(source);
|
|
32
|
+
return `${prefix(source, withPrefix)}${color}${text}${RESET}\n`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {{source: string|null, toolName: string, hint: string, withPrefix: boolean}} args
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
export function renderToolCallLine({ source, toolName, hint, withPrefix }) {
|
|
40
|
+
const color = colorForSource(source);
|
|
41
|
+
const body = hint ? `${toolName}: ${hint}` : `${toolName}`;
|
|
42
|
+
return `${prefix(source, withPrefix)}${color}${body}${RESET}\n`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {{source: string|null, preview: {text: string, isError: boolean}, withPrefix: boolean}} args
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
export function renderToolResultLine({ source, preview, withPrefix }) {
|
|
50
|
+
const color = preview.isError ? ERROR_COLOR : colorForSource(source);
|
|
51
|
+
const label = preview.isError ? "Error" : "Result";
|
|
52
|
+
const body = `${label}: ${preview.text}`;
|
|
53
|
+
return `${prefix(source, withPrefix)}${color}${body}${RESET}\n`;
|
|
54
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestrator filter — predicate for the orchestrator lifecycle events that
|
|
3
|
+
* should be suppressed from the human-readable log.
|
|
4
|
+
*
|
|
5
|
+
* NDJSON artifacts still carry every orchestrator event; this module only
|
|
6
|
+
* controls what the live `textStream` and offline `toText()` show.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const SUPPRESSED = new Set(["session_start", "agent_start", "summary", "meta"]);
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {{type?: string}|null|undefined} event
|
|
13
|
+
* @returns {boolean} true when the event's type is one we hide from text output
|
|
14
|
+
*/
|
|
15
|
+
export function isSuppressedOrchestratorEvent(event) {
|
|
16
|
+
return Boolean(
|
|
17
|
+
event && typeof event === "object" && SUPPRESSED.has(event.type),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Palette — pure profile-name → ANSI SGR foreground color function.
|
|
3
|
+
*
|
|
4
|
+
* Assignment is a FNV-1a hash of the source name modulo the palette size, so
|
|
5
|
+
* the same name maps to the same color in every process. Red is reserved for
|
|
6
|
+
* tool-result errors and is never in the palette.
|
|
7
|
+
*
|
|
8
|
+
* Colors use the 24-bit truecolor SGR escape (`ESC[38;2;R;G;Bm`) rather than
|
|
9
|
+
* the 16-color table. GitHub Actions' log viewer and most modern terminals
|
|
10
|
+
* render truecolor as the exact hex requested, avoiding the washed-out
|
|
11
|
+
* mustard/olive tones GHA applies to `ESC[93m` etc. Eight slots cover the
|
|
12
|
+
* largest concurrent cast in any existing workflow (five domain agents plus
|
|
13
|
+
* the facilitator) with headroom.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const PALETTE = [
|
|
17
|
+
"\u001b[38;2;79;195;247m", // sky blue #4FC3F7
|
|
18
|
+
"\u001b[38;2;129;199;132m", // bright green #81C784
|
|
19
|
+
"\u001b[38;2;255;202;40m", // amber #FFCA28
|
|
20
|
+
"\u001b[38;2;236;64;122m", // magenta #EC407A
|
|
21
|
+
"\u001b[38;2;38;198;218m", // cyan #26C6DA
|
|
22
|
+
"\u001b[38;2;186;104;200m", // lavender #BA68C8
|
|
23
|
+
"\u001b[38;2;255;167;38m", // orange #FFA726
|
|
24
|
+
"\u001b[38;2;66;165;245m", // blue #42A5F5
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** 24-bit SGR foreground code reserved for tool-result errors (#F14C4C). */
|
|
28
|
+
export const ERROR_COLOR = "\u001b[38;2;241;76;76m";
|
|
29
|
+
|
|
30
|
+
/** ANSI SGR reset sequence. */
|
|
31
|
+
export const RESET = "\u001b[0m";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Map a source name to a stable ANSI foreground color.
|
|
35
|
+
*
|
|
36
|
+
* The mapping is a pure function of the name via FNV-1a 32-bit hash — same
|
|
37
|
+
* name, same color, every call, in every process. Returns `RESET` for
|
|
38
|
+
* missing/empty names so callers never emit a stray escape.
|
|
39
|
+
*
|
|
40
|
+
* @param {string|null|undefined} name
|
|
41
|
+
* @returns {string} ANSI SGR escape, never equal to `ERROR_COLOR`
|
|
42
|
+
*/
|
|
43
|
+
export function colorForSource(name) {
|
|
44
|
+
if (!name) return RESET;
|
|
45
|
+
let h = 0x811c9dc5;
|
|
46
|
+
for (let i = 0; i < name.length; i++) {
|
|
47
|
+
h ^= name.charCodeAt(i);
|
|
48
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
49
|
+
}
|
|
50
|
+
// Length mixer: reduces FNV's intrinsic birthday collisions on short
|
|
51
|
+
// names with shared affixes (e.g. `staff-engineer`/`facilitator`).
|
|
52
|
+
h ^= name.length;
|
|
53
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
54
|
+
return PALETTE[h % PALETTE.length];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Expose the palette size so tests can assert distinctness on the full set.
|
|
59
|
+
* @returns {number}
|
|
60
|
+
*/
|
|
61
|
+
export function paletteSize() {
|
|
62
|
+
return PALETTE.length;
|
|
63
|
+
}
|