@gethmy/mcp 3.3.0 → 3.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -0
- package/dist/cli.js +1012 -178
- package/dist/index.js +630 -63
- package/dist/run-hook-cli.js +742 -0
- package/package.json +2 -2
- package/src/api-client.ts +26 -1
- package/src/auto-session.ts +33 -0
- package/src/cli.ts +104 -0
- package/src/hook-install.ts +388 -0
- package/src/plan-task-link.ts +3 -1
- package/src/run-event-forwarder.ts +363 -0
- package/src/run-hook-cli.ts +55 -0
- package/src/run-hook-main.ts +159 -0
- package/src/run-hook.ts +203 -0
- package/src/run-redaction.ts +461 -0
- package/src/run-state.ts +679 -0
- package/src/server.ts +85 -5
package/src/run-hook.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning one `PostToolUse` hook payload into two run events (#874).
|
|
3
|
+
*
|
|
4
|
+
* The harness fires this hook after every tool call and hands it the tool name,
|
|
5
|
+
* the input it was called with and the result it produced. That is the whole
|
|
6
|
+
* log an MCP session never had — the MCP server sees only its own tools, so
|
|
7
|
+
* Read, Edit, Bash and Grep are invisible to it and no amount of server-side
|
|
8
|
+
* work can recover them.
|
|
9
|
+
*
|
|
10
|
+
* ## Why a pair, from a POST hook
|
|
11
|
+
*
|
|
12
|
+
* The timeline correlates `tool_started` and `tool_ended` by `toolUseId` into
|
|
13
|
+
* ONE row (`deriveTimeline`, `src/lib/agentTimelineModel.ts`), and that row is
|
|
14
|
+
* what renders input and output behind an expander. `PostToolUse` fires once,
|
|
15
|
+
* after the call, holding both halves — so it emits both halves, with the same
|
|
16
|
+
* correlation id and `createdAt` timestamps one millisecond apart so they sort
|
|
17
|
+
* in the right order under the server-assigned `seq`.
|
|
18
|
+
*
|
|
19
|
+
* A `PreToolUse` hook would give a truer "started" timestamp. It is not worth
|
|
20
|
+
* it: it would double the per-tool-call hook cost, on the critical path of
|
|
21
|
+
* every `Read`, to move a bar that renders as a single row either way.
|
|
22
|
+
*
|
|
23
|
+
* ## Harmony's own MCP tools are included, deliberately
|
|
24
|
+
*
|
|
25
|
+
* A `/hmy` session calls `mcp__harmony__*` tools constantly, and it is tempting
|
|
26
|
+
* to filter them out as noise. They stay, for one reason: a DAEMON run's
|
|
27
|
+
* timeline already shows its MCP calls, because the daemon's stream parser does
|
|
28
|
+
* not distinguish them either. Filtering here would make the two runtimes'
|
|
29
|
+
* timelines disagree about what a tool call is, and the point of this card is
|
|
30
|
+
* that they should read the same.
|
|
31
|
+
*
|
|
32
|
+
* This module is pure. The process that calls it does the I/O.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { redactToolCall } from "./run-redaction.js";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The subset of the harness's `PostToolUse` stdin payload this reads.
|
|
39
|
+
*
|
|
40
|
+
* Everything is optional because the shape is the harness's to change, and a
|
|
41
|
+
* hook that throws on an unfamiliar field would break the user's tool call —
|
|
42
|
+
* the one thing it must never do. An unusable payload yields no events.
|
|
43
|
+
*/
|
|
44
|
+
export interface PostToolUsePayload {
|
|
45
|
+
session_id?: string;
|
|
46
|
+
transcript_path?: string;
|
|
47
|
+
cwd?: string;
|
|
48
|
+
hook_event_name?: string;
|
|
49
|
+
tool_name?: string;
|
|
50
|
+
tool_input?: unknown;
|
|
51
|
+
tool_response?: unknown;
|
|
52
|
+
tool_use_id?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The event shape written to the spool. Matches `AgentRunEventDraft`. */
|
|
56
|
+
export interface SpooledRunEvent {
|
|
57
|
+
kind: "tool_started" | "tool_ended";
|
|
58
|
+
source: "agent";
|
|
59
|
+
payload: Record<string, unknown>;
|
|
60
|
+
createdAt: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Pull readable text out of whatever the harness put in `tool_response`.
|
|
65
|
+
*
|
|
66
|
+
* The field is not one shape. A `Bash` result is an object with `stdout` and
|
|
67
|
+
* `stderr`; a `Read` is an object with `file`; an MCP tool result is an array
|
|
68
|
+
* of content blocks; some tools return a bare string. Rather than switch on the
|
|
69
|
+
* tool name — which would go stale the moment a tool is added — this reads the
|
|
70
|
+
* shapes, in the order that produces the most useful text.
|
|
71
|
+
*/
|
|
72
|
+
export function extractOutputText(response: unknown, depth = 0): string {
|
|
73
|
+
if (depth > 4) return "";
|
|
74
|
+
if (response === null || response === undefined) return "";
|
|
75
|
+
if (typeof response === "string") return response;
|
|
76
|
+
if (typeof response === "number" || typeof response === "boolean") {
|
|
77
|
+
return String(response);
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(response)) {
|
|
80
|
+
return response
|
|
81
|
+
.map((item) => extractOutputText(item, depth + 1))
|
|
82
|
+
.filter((part) => part.length > 0)
|
|
83
|
+
.join("\n");
|
|
84
|
+
}
|
|
85
|
+
if (typeof response === "object") {
|
|
86
|
+
const record = response as Record<string, unknown>;
|
|
87
|
+
// Content-block shape: { type: "text", text: "…" }
|
|
88
|
+
if (typeof record.text === "string") return record.text;
|
|
89
|
+
const parts: string[] = [];
|
|
90
|
+
for (const key of ["stdout", "stderr", "output", "content", "result"]) {
|
|
91
|
+
const value = record[key];
|
|
92
|
+
if (value === undefined || value === null) continue;
|
|
93
|
+
const text = extractOutputText(value, depth + 1);
|
|
94
|
+
if (text.length > 0) parts.push(text);
|
|
95
|
+
}
|
|
96
|
+
if (parts.length > 0) return parts.join("\n");
|
|
97
|
+
// Nothing recognizable — serialize, so the row is not silently empty.
|
|
98
|
+
try {
|
|
99
|
+
const serialized = JSON.stringify(record) ?? "";
|
|
100
|
+
// A tool that returned nothing should render as NO output, not as a
|
|
101
|
+
// literal `{}` — an empty expander is noise a reader has to open to
|
|
102
|
+
// discover is empty.
|
|
103
|
+
return serialized === "{}" || serialized === "[]" ? "" : serialized;
|
|
104
|
+
} catch {
|
|
105
|
+
return "";
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return "";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Did the tool fail? Read from the flags the harness sets, never inferred. */
|
|
112
|
+
export function extractIsError(response: unknown): boolean {
|
|
113
|
+
if (response === null || typeof response !== "object") return false;
|
|
114
|
+
const record = response as Record<string, unknown>;
|
|
115
|
+
if (record.is_error === true || record.isError === true) return true;
|
|
116
|
+
if (record.interrupted === true) return true;
|
|
117
|
+
if (typeof record.error === "string" && record.error.length > 0) return true;
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A correlation id for the pair.
|
|
123
|
+
*
|
|
124
|
+
* The harness supplies one on most payloads; when it does not, a synthesized id
|
|
125
|
+
* still correlates correctly because both halves of THIS pair are built from
|
|
126
|
+
* the same call. It is prefixed so a reader can tell a synthesized id from a
|
|
127
|
+
* real one when a row looks wrong.
|
|
128
|
+
*/
|
|
129
|
+
function correlationId(
|
|
130
|
+
payload: PostToolUsePayload,
|
|
131
|
+
nonce: () => string,
|
|
132
|
+
): string {
|
|
133
|
+
const supplied = payload.tool_use_id;
|
|
134
|
+
if (typeof supplied === "string" && supplied.length > 0) return supplied;
|
|
135
|
+
return `hook-${nonce()}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Build the `tool_started` / `tool_ended` pair for one tool call.
|
|
140
|
+
*
|
|
141
|
+
* Returns an empty array when there is nothing worth writing — no tool name, or
|
|
142
|
+
* a payload from a hook event this does not handle. An empty array is the
|
|
143
|
+
* caller's cue to do nothing at all, not to write an empty batch.
|
|
144
|
+
*/
|
|
145
|
+
export function buildHookEvents(
|
|
146
|
+
payload: PostToolUsePayload,
|
|
147
|
+
options?: { now?: number; nonce?: () => string },
|
|
148
|
+
): SpooledRunEvent[] {
|
|
149
|
+
const toolName =
|
|
150
|
+
typeof payload?.tool_name === "string" ? payload.tool_name.trim() : "";
|
|
151
|
+
if (!toolName) return [];
|
|
152
|
+
if (
|
|
153
|
+
typeof payload.hook_event_name === "string" &&
|
|
154
|
+
payload.hook_event_name !== "PostToolUse"
|
|
155
|
+
) {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const now = options?.now ?? Date.now();
|
|
160
|
+
const nonce =
|
|
161
|
+
options?.nonce ?? (() => Math.random().toString(36).slice(2, 12));
|
|
162
|
+
const toolUseId = correlationId(payload, nonce);
|
|
163
|
+
|
|
164
|
+
const rawOutput = extractOutputText(payload.tool_response);
|
|
165
|
+
const isError = extractIsError(payload.tool_response);
|
|
166
|
+
const redacted = redactToolCall({
|
|
167
|
+
input: payload.tool_input,
|
|
168
|
+
output: rawOutput,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const startPayload: Record<string, unknown> = { toolName, toolUseId };
|
|
172
|
+
const endPayload: Record<string, unknown> = { toolName, toolUseId };
|
|
173
|
+
|
|
174
|
+
if (redacted.withheld) {
|
|
175
|
+
// The row survives so the timeline still shows the call happened; only the
|
|
176
|
+
// bytes are gone. `withheld` is a plain payload field — the schema is open,
|
|
177
|
+
// and an unknown field is stored and ignored rather than rejected.
|
|
178
|
+
startPayload.withheld = redacted.withheld;
|
|
179
|
+
endPayload.withheld = redacted.withheld;
|
|
180
|
+
endPayload.output = `[withheld: ${redacted.withheld}]`;
|
|
181
|
+
} else {
|
|
182
|
+
if (redacted.input !== undefined) startPayload.input = redacted.input;
|
|
183
|
+
if (redacted.output !== undefined) endPayload.output = redacted.output;
|
|
184
|
+
}
|
|
185
|
+
if (isError) endPayload.isError = true;
|
|
186
|
+
|
|
187
|
+
return [
|
|
188
|
+
{
|
|
189
|
+
kind: "tool_started",
|
|
190
|
+
source: "agent",
|
|
191
|
+
payload: startPayload,
|
|
192
|
+
// One millisecond apart so the pair keeps its order when the server
|
|
193
|
+
// assigns `seq` from insert order within a batch.
|
|
194
|
+
createdAt: new Date(now).toISOString(),
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
kind: "tool_ended",
|
|
198
|
+
source: "agent",
|
|
199
|
+
payload: endPayload,
|
|
200
|
+
createdAt: new Date(now + 1).toISOString(),
|
|
201
|
+
},
|
|
202
|
+
];
|
|
203
|
+
}
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a tool call may say on a shared board (card #874).
|
|
3
|
+
*
|
|
4
|
+
* A `PostToolUse` hook sees the raw argument and the raw result of every tool
|
|
5
|
+
* call an MCP session makes, and this module decides how much of that reaches
|
|
6
|
+
* `agent_run_events`. The board is shared: a card's timeline is readable by
|
|
7
|
+
* every member of the workspace, and by anyone a card is shared with. So the
|
|
8
|
+
* question is not "can we send this" but "would we paste it into a team chat".
|
|
9
|
+
*
|
|
10
|
+
* ## Three rules, in the order they fire
|
|
11
|
+
*
|
|
12
|
+
* 1. **Withhold by path.** A tool whose input names a credential file gets its
|
|
13
|
+
* input AND its output dropped, replaced by a reason. The row survives — the
|
|
14
|
+
* timeline still shows that a `Read` happened — but the bytes never leave the
|
|
15
|
+
* machine. This is the only rule that withholds rather than edits, because a
|
|
16
|
+
* `.env` has no safe prefix to truncate to: the first line is the secret.
|
|
17
|
+
* 2. **Redact by pattern.** Anything that survives rule 1 is swept for secret
|
|
18
|
+
* SHAPES — a token, a private key block, a URL with a password in it, a
|
|
19
|
+
* `FOO_SECRET=` assignment. This catches the case rule 1 cannot see: a
|
|
20
|
+
* secret that was never in a file, like `curl -H "Authorization: Bearer …"`.
|
|
21
|
+
* 3. **Truncate.** What is left is capped, so one `Read` of a 2 MB file cannot
|
|
22
|
+
* blow the 16 KB server-side payload limit
|
|
23
|
+
* (`MAX_RUN_EVENT_PAYLOAD_BYTES`, `_shared/run-event-validation.ts`).
|
|
24
|
+
*
|
|
25
|
+
* ## Why rule 1's list mirrors `credentialDirectories()`
|
|
26
|
+
*
|
|
27
|
+
* `packages/harmony-harness/src/run-containment.ts` already answered "which
|
|
28
|
+
* directories hold a credential" for the sandbox's `denyRead` list. The same
|
|
29
|
+
* answer applies here for a different reason — that list fences a contained
|
|
30
|
+
* run OUT of those files, this one keeps their contents OFF the board — so the
|
|
31
|
+
* two are kept deliberately parallel.
|
|
32
|
+
*
|
|
33
|
+
* "Keep them parallel" was a sentence, and a sentence did not hold: the first
|
|
34
|
+
* version of this file omitted `~/.claude`, the directory holding Claude Code's
|
|
35
|
+
* own OAuth token, which this repo has already seen a run be talked into
|
|
36
|
+
* reading. So the parallel is now mechanical — `HARNESS_CREDENTIAL_LEAVES`
|
|
37
|
+
* below transcribes the harness list, and a test walks it and asserts every
|
|
38
|
+
* entry is withheld. **When the harness list grows, grow that constant**; the
|
|
39
|
+
* test then tells you whether the rules already cover the new entry.
|
|
40
|
+
*
|
|
41
|
+
* Everything here is pure and synchronous. The hook that calls it runs on the
|
|
42
|
+
* critical path of every single tool call, so it may not do I/O, and it is
|
|
43
|
+
* table-tested rather than reconstructed from a live run.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** Cap on the serialized tool input. */
|
|
47
|
+
export const MAX_INPUT_CHARS = 2_000;
|
|
48
|
+
/**
|
|
49
|
+
* Cap on tool output. Matches `MAX_OUTPUT_LEN` in the daemon's
|
|
50
|
+
* `cli-agent-runner.ts`, so an MCP session's rows truncate exactly where a
|
|
51
|
+
* daemon run's rows do and the two read the same on one timeline.
|
|
52
|
+
*/
|
|
53
|
+
export const MAX_OUTPUT_CHARS = 4_000;
|
|
54
|
+
/** Cap on any single string leaf inside a structured input. */
|
|
55
|
+
export const MAX_INPUT_STRING_CHARS = 600;
|
|
56
|
+
|
|
57
|
+
/** What replaces a redacted span. Distinctive on purpose — it is greppable. */
|
|
58
|
+
export const REDACTION_MARK = "«redacted»";
|
|
59
|
+
|
|
60
|
+
/** Reason codes, so a withheld row says WHY rather than just going blank. */
|
|
61
|
+
export type WithholdReason = "sensitive-path";
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Path segments that are credential stores. A path containing any of these as a
|
|
65
|
+
* whole segment is sensitive regardless of the file name inside it.
|
|
66
|
+
*
|
|
67
|
+
* Kept in step with `credentialDirectories()` in the harness — see the module
|
|
68
|
+
* doc. `.harmony-mcp` is here for the same reason it is first there: it holds
|
|
69
|
+
* this product's own API key.
|
|
70
|
+
*/
|
|
71
|
+
const SENSITIVE_SEGMENTS: readonly string[] = [
|
|
72
|
+
".ssh",
|
|
73
|
+
".gnupg",
|
|
74
|
+
".aws",
|
|
75
|
+
".codex",
|
|
76
|
+
".gemini",
|
|
77
|
+
".docker",
|
|
78
|
+
".kube",
|
|
79
|
+
".harmony-mcp",
|
|
80
|
+
".password-store",
|
|
81
|
+
// `~/.claude` holds `.credentials.json`, Claude Code's own OAuth token, and
|
|
82
|
+
// this repo has already watched a run be talked into reading it and pasting
|
|
83
|
+
// the contents into a source comment (`confine-to-repo.ts`, `ci-repair.ts`,
|
|
84
|
+
// `ci-patch.ts` all record that measurement). The harness denies the whole
|
|
85
|
+
// directory for that reason; this list omitted it, so a `Read` of the token
|
|
86
|
+
// file would have reached the board in full.
|
|
87
|
+
//
|
|
88
|
+
// Matched UNCONDITIONALLY, not only under `$HOME`, and that is deliberate.
|
|
89
|
+
// A repo's own `.claude/settings.local.json` is gitignored precisely because
|
|
90
|
+
// it is personal, and the key it most often carries is `env` — tokens. So
|
|
91
|
+
// "project layer, therefore safe" is false, and anchoring on the home
|
|
92
|
+
// directory would have to be right about which of the two a path is. The
|
|
93
|
+
// cost of being unconditional is a blank row for a `Read` of a skill or a
|
|
94
|
+
// settings file; the cost of being wrong the other way is an OAuth token on
|
|
95
|
+
// a shared board. Same asymmetry `.env.example` is decided on below.
|
|
96
|
+
".claude",
|
|
97
|
+
"gh",
|
|
98
|
+
"gcloud",
|
|
99
|
+
"op",
|
|
100
|
+
"anthropic",
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The leaf names of `credentialDirectories()`, hand-transcribed.
|
|
105
|
+
*
|
|
106
|
+
* This is the mirror the module doc's "when one grows, grow the other" asks
|
|
107
|
+
* for, made mechanical: `run-redaction.test.ts` walks this list and asserts
|
|
108
|
+
* every entry is withheld, so an entry added to the harness and forgotten here
|
|
109
|
+
* fails a test instead of shipping. It was a comment before, and the comment
|
|
110
|
+
* did not stop `.claude` from going missing.
|
|
111
|
+
*
|
|
112
|
+
* Transcribed rather than imported because `@gethmy/harness` is not a
|
|
113
|
+
* dependency of the published `@gethmy/mcp` package and must not become one for
|
|
114
|
+
* a test — the same reason `agent-run-event-kinds_test.ts` hand-transcribes its
|
|
115
|
+
* list.
|
|
116
|
+
*/
|
|
117
|
+
export const HARNESS_CREDENTIAL_LEAVES: readonly {
|
|
118
|
+
/** Path relative to the home directory, exactly as the harness spells it. */
|
|
119
|
+
readonly path: string;
|
|
120
|
+
/**
|
|
121
|
+
* Directory or file. The harness list mixes the two — its own deny rules need
|
|
122
|
+
* `/**` for one and not the other — and the distinction matters here as well:
|
|
123
|
+
* for a directory the test must prove a file INSIDE it is withheld, which is
|
|
124
|
+
* the shape an exfiltration actually takes.
|
|
125
|
+
*/
|
|
126
|
+
readonly kind: "dir" | "file";
|
|
127
|
+
}[] = [
|
|
128
|
+
{ path: ".harmony-mcp", kind: "dir" }, // getConfigDir()
|
|
129
|
+
{ path: ".claude", kind: "dir" },
|
|
130
|
+
{ path: ".claude.json", kind: "file" },
|
|
131
|
+
{ path: ".ssh", kind: "dir" },
|
|
132
|
+
{ path: ".gnupg", kind: "dir" },
|
|
133
|
+
{ path: ".aws", kind: "dir" },
|
|
134
|
+
{ path: ".codex", kind: "dir" },
|
|
135
|
+
{ path: ".gemini", kind: "dir" },
|
|
136
|
+
{ path: ".config/gh", kind: "dir" },
|
|
137
|
+
{ path: ".config/gcloud", kind: "dir" },
|
|
138
|
+
{ path: ".config/anthropic", kind: "dir" },
|
|
139
|
+
{ path: ".config/op", kind: "dir" },
|
|
140
|
+
{ path: ".docker", kind: "dir" },
|
|
141
|
+
{ path: ".kube", kind: "dir" },
|
|
142
|
+
{ path: ".netrc", kind: "file" },
|
|
143
|
+
{ path: ".npmrc", kind: "file" },
|
|
144
|
+
{ path: ".git-credentials", kind: "file" },
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The `gh` / `gcloud` / `op` / `anthropic` entries above are single common words
|
|
149
|
+
* and would otherwise match `src/gh/…`. They count only directly under a
|
|
150
|
+
* `.config` directory, which is where the harness names them.
|
|
151
|
+
*/
|
|
152
|
+
const CONFIG_SCOPED_SEGMENTS: ReadonlySet<string> = new Set([
|
|
153
|
+
"gh",
|
|
154
|
+
"gcloud",
|
|
155
|
+
"op",
|
|
156
|
+
"anthropic",
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* File names that are a credential whatever directory they sit in.
|
|
161
|
+
*
|
|
162
|
+
* The dot-prefixed spellings sit beside their bare ones on purpose. A basename
|
|
163
|
+
* set is an exact match, so `credentials.json` does not cover
|
|
164
|
+
* `.credentials.json` — and `.credentials.json` is the one that holds Claude
|
|
165
|
+
* Code's OAuth token. The directory rule above already withholds it; this is
|
|
166
|
+
* the second, independent catch, because a token file is worth two.
|
|
167
|
+
*/
|
|
168
|
+
const SENSITIVE_BASENAMES: ReadonlySet<string> = new Set([
|
|
169
|
+
".netrc",
|
|
170
|
+
"_netrc",
|
|
171
|
+
".npmrc",
|
|
172
|
+
".pgpass",
|
|
173
|
+
".git-credentials",
|
|
174
|
+
".htpasswd",
|
|
175
|
+
".claude.json",
|
|
176
|
+
"credentials",
|
|
177
|
+
".credentials",
|
|
178
|
+
"credentials.json",
|
|
179
|
+
".credentials.json",
|
|
180
|
+
"credentials.yml",
|
|
181
|
+
"credentials.yaml",
|
|
182
|
+
// Codex, and the shape several other runtimes reuse for a token cache.
|
|
183
|
+
"auth.json",
|
|
184
|
+
".auth.json",
|
|
185
|
+
"secrets",
|
|
186
|
+
"secrets.json",
|
|
187
|
+
"secrets.yaml",
|
|
188
|
+
"secrets.yml",
|
|
189
|
+
"id_rsa",
|
|
190
|
+
"id_dsa",
|
|
191
|
+
"id_ecdsa",
|
|
192
|
+
"id_ed25519",
|
|
193
|
+
"known_hosts",
|
|
194
|
+
]);
|
|
195
|
+
|
|
196
|
+
/** Extensions that are a key or a keystore. */
|
|
197
|
+
const SENSITIVE_EXTENSIONS: readonly string[] = [
|
|
198
|
+
".pem",
|
|
199
|
+
".key",
|
|
200
|
+
".p12",
|
|
201
|
+
".pfx",
|
|
202
|
+
".keystore",
|
|
203
|
+
".jks",
|
|
204
|
+
".asc",
|
|
205
|
+
".gpg",
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Is this path a credential?
|
|
210
|
+
*
|
|
211
|
+
* Deliberately conservative in two places. `.env.example` is withheld along
|
|
212
|
+
* with `.env`, because telling them apart means trusting a naming convention
|
|
213
|
+
* that nothing enforces, and the cost of being wrong is asymmetric: a withheld
|
|
214
|
+
* example file is a missing timeline row, a leaked `.env` is an incident.
|
|
215
|
+
* Likewise `known_hosts` — not a secret, but it enumerates the machines an
|
|
216
|
+
* operator reaches, which is not board material either.
|
|
217
|
+
*/
|
|
218
|
+
export function isSensitivePath(rawPath: string): boolean {
|
|
219
|
+
if (typeof rawPath !== "string" || rawPath.length === 0) return false;
|
|
220
|
+
const path = rawPath.trim().toLowerCase();
|
|
221
|
+
// Normalize both separators so a Windows-shaped path is judged the same.
|
|
222
|
+
const segments = path.split(/[\\/]+/).filter((s) => s.length > 0);
|
|
223
|
+
if (segments.length === 0) return false;
|
|
224
|
+
|
|
225
|
+
for (let i = 0; i < segments.length; i++) {
|
|
226
|
+
const segment = segments[i] as string;
|
|
227
|
+
if (!SENSITIVE_SEGMENTS.includes(segment)) continue;
|
|
228
|
+
if (CONFIG_SCOPED_SEGMENTS.has(segment)) {
|
|
229
|
+
// Only when it sits directly under `.config`, per the harness list.
|
|
230
|
+
if (i > 0 && segments[i - 1] === ".config") return true;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const basename = segments[segments.length - 1] as string;
|
|
237
|
+
if (SENSITIVE_BASENAMES.has(basename)) return true;
|
|
238
|
+
// `.env`, `.env.local`, `.env.production` — and `.env.example`, on purpose.
|
|
239
|
+
if (basename === ".env" || basename.startsWith(".env.")) return true;
|
|
240
|
+
// `foo.env` reads as an environment file too.
|
|
241
|
+
if (basename.endsWith(".env")) return true;
|
|
242
|
+
if (SENSITIVE_EXTENSIONS.some((ext) => basename.endsWith(ext))) return true;
|
|
243
|
+
// `serviceAccount.json`, `service-account-key.json`, …
|
|
244
|
+
if (/service[-_]?account.*\.json$/.test(basename)) return true;
|
|
245
|
+
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Every string in `input` that looks like a filesystem path and is sensitive.
|
|
251
|
+
*
|
|
252
|
+
* Walks the whole structure rather than reading a known key, because the key
|
|
253
|
+
* differs per tool (`file_path` on Read/Edit, `path` on Glob, `notebook_path`
|
|
254
|
+
* on NotebookEdit) and a tool this code has never heard of is exactly the one
|
|
255
|
+
* that would slip through a per-tool lookup.
|
|
256
|
+
*/
|
|
257
|
+
export function sensitivePathsIn(input: unknown, depth = 0): string[] {
|
|
258
|
+
if (depth > 6) return [];
|
|
259
|
+
if (typeof input === "string") {
|
|
260
|
+
return isSensitivePath(input) ? [input] : [];
|
|
261
|
+
}
|
|
262
|
+
if (Array.isArray(input)) {
|
|
263
|
+
return input.flatMap((item) => sensitivePathsIn(item, depth + 1));
|
|
264
|
+
}
|
|
265
|
+
if (input !== null && typeof input === "object") {
|
|
266
|
+
return Object.values(input as Record<string, unknown>).flatMap((value) =>
|
|
267
|
+
sensitivePathsIn(value, depth + 1),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
return [];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Secret SHAPES, swept over any text that survives the path rule.
|
|
275
|
+
*
|
|
276
|
+
* Each entry replaces the whole match, or — where a capture group is present —
|
|
277
|
+
* keeps the group and replaces the rest, so `GITHUB_TOKEN=…` stays legible as
|
|
278
|
+
* `GITHUB_TOKEN=«redacted»`. Knowing WHICH secret was passed is often the point
|
|
279
|
+
* of the timeline row; knowing its value never is.
|
|
280
|
+
*/
|
|
281
|
+
const SECRET_PATTERNS: readonly { pattern: RegExp; replace: string }[] = [
|
|
282
|
+
// A PEM block, first — it spans lines and would otherwise be truncated into
|
|
283
|
+
// a still-usable prefix by rule 3.
|
|
284
|
+
{
|
|
285
|
+
pattern: /-----BEGIN[^-]*PRIVATE KEY-----[\s\S]*?-----END[^-]*-----/g,
|
|
286
|
+
replace: REDACTION_MARK,
|
|
287
|
+
},
|
|
288
|
+
// Harmony's own credentials. `hmy_at_` is the OAuth shape, `hmy_` the
|
|
289
|
+
// integration key; the longer alternative is written first so it wins.
|
|
290
|
+
{ pattern: /\bhmy_at_[A-Za-z0-9_-]{8,}/g, replace: REDACTION_MARK },
|
|
291
|
+
{ pattern: /\bhmy_[A-Za-z0-9_-]{8,}/g, replace: REDACTION_MARK },
|
|
292
|
+
// Anthropic / OpenAI.
|
|
293
|
+
{ pattern: /\bsk-(?:ant-)?[A-Za-z0-9_-]{16,}/g, replace: REDACTION_MARK },
|
|
294
|
+
// GitHub: classic PAT prefixes and the fine-grained shape.
|
|
295
|
+
{ pattern: /\bgh[pousr]_[A-Za-z0-9]{16,}/g, replace: REDACTION_MARK },
|
|
296
|
+
{ pattern: /\bgithub_pat_[A-Za-z0-9_]{20,}/g, replace: REDACTION_MARK },
|
|
297
|
+
// Slack.
|
|
298
|
+
{ pattern: /\bxox[abprs]-[A-Za-z0-9-]{10,}/g, replace: REDACTION_MARK },
|
|
299
|
+
// AWS access key id, Google API key.
|
|
300
|
+
{ pattern: /\bAKIA[0-9A-Z]{16}\b/g, replace: REDACTION_MARK },
|
|
301
|
+
{ pattern: /\bAIza[0-9A-Za-z_-]{20,}/g, replace: REDACTION_MARK },
|
|
302
|
+
// A JWT — three base64url segments. Catches Supabase anon/service keys.
|
|
303
|
+
{
|
|
304
|
+
pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
|
|
305
|
+
replace: REDACTION_MARK,
|
|
306
|
+
},
|
|
307
|
+
// `Authorization: Bearer <token>` and friends.
|
|
308
|
+
{
|
|
309
|
+
pattern: /\b(Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{12,}/gi,
|
|
310
|
+
replace: `$1 ${REDACTION_MARK}`,
|
|
311
|
+
},
|
|
312
|
+
// A URL with userinfo: https://user:password@host
|
|
313
|
+
{
|
|
314
|
+
pattern: /(\w+:\/\/)[^/\s:@]+:[^/\s@]+@/g,
|
|
315
|
+
replace: `$1${REDACTION_MARK}@`,
|
|
316
|
+
},
|
|
317
|
+
// An assignment whose NAME says it is a secret. Keeps the name.
|
|
318
|
+
//
|
|
319
|
+
// The two `[A-Za-z0-9_]` runs are bounded rather than `*`. Unbounded, they
|
|
320
|
+
// backtrack quadratically over a long alphanumeric blob — a 500 KB `Write`
|
|
321
|
+
// payload took 147 seconds and blew a 5-second test timeout. A real
|
|
322
|
+
// environment variable name is nowhere near 40 characters, so the bound costs
|
|
323
|
+
// nothing and turns O(n²) into O(n).
|
|
324
|
+
{
|
|
325
|
+
pattern:
|
|
326
|
+
/\b([A-Za-z0-9_]{0,40}(?:TOKEN|SECRET|PASSWORD|PASSWD|APIKEY|API_KEY|ACCESS_KEY|PRIVATE_KEY|CREDENTIAL|AUTH)[A-Za-z0-9_]{0,40})\s*[=:]\s*(?:"[^"]*"|'[^']*'|`[^`]*`|[^\s,;)}\]]+)/gi,
|
|
327
|
+
replace: `$1=${REDACTION_MARK}`,
|
|
328
|
+
},
|
|
329
|
+
// A command-line flag whose NAME says it is a secret.
|
|
330
|
+
{
|
|
331
|
+
pattern:
|
|
332
|
+
/(--?(?:password|passwd|token|api-?key|secret|auth)(?:=|\s+))(?:"[^"]*"|'[^']*'|[^\s]+)/gi,
|
|
333
|
+
replace: `$1${REDACTION_MARK}`,
|
|
334
|
+
},
|
|
335
|
+
];
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Sweep `text` for secret shapes.
|
|
339
|
+
*
|
|
340
|
+
* Order matters and is fixed by `SECRET_PATTERNS`: the PEM block runs first so
|
|
341
|
+
* a key body is gone before any narrower pattern chews on its base64.
|
|
342
|
+
*/
|
|
343
|
+
export function redactSecrets(text: string): string {
|
|
344
|
+
if (typeof text !== "string" || text.length === 0) return text;
|
|
345
|
+
let out = text;
|
|
346
|
+
for (const { pattern, replace } of SECRET_PATTERNS) {
|
|
347
|
+
// Each regex is `g`-flagged and shared, so reset before reuse.
|
|
348
|
+
pattern.lastIndex = 0;
|
|
349
|
+
out = out.replace(pattern, replace);
|
|
350
|
+
}
|
|
351
|
+
return out;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Cut `text` to `max`, marking the cut so a reader knows it happened. */
|
|
355
|
+
export function truncate(
|
|
356
|
+
text: string,
|
|
357
|
+
max: number,
|
|
358
|
+
originalLength?: number,
|
|
359
|
+
): string {
|
|
360
|
+
const total = originalLength ?? text.length;
|
|
361
|
+
if (total <= max) return text;
|
|
362
|
+
return `${text.slice(0, max)}… [+${total - max} chars]`;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Redact, then cut to `max`.
|
|
367
|
+
*
|
|
368
|
+
* The order is deliberate and so is the pre-cap. Redacting the WHOLE of a
|
|
369
|
+
* multi-megabyte tool result before throwing 99% of it away is wasted work on
|
|
370
|
+
* the critical path of every tool call, so the sweep sees at most a small
|
|
371
|
+
* multiple of what can survive. Cutting first and redacting after would be
|
|
372
|
+
* cheaper still and is wrong: it would leave a secret that straddles the cut
|
|
373
|
+
* as a usable prefix. Anything between `max` and the pre-cap IS redacted and
|
|
374
|
+
* then discarded; anything past the pre-cap is discarded without ever being
|
|
375
|
+
* emitted, so nothing unexamined can reach the board.
|
|
376
|
+
*/
|
|
377
|
+
function redactThenTruncate(text: string, max: number): string {
|
|
378
|
+
const preCap = max * 4 + 64;
|
|
379
|
+
const scanned = text.length > preCap ? text.slice(0, preCap) : text;
|
|
380
|
+
return truncate(redactSecrets(scanned), max, text.length);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Redact and cap every string leaf of a structured value.
|
|
385
|
+
*
|
|
386
|
+
* Structure is preserved rather than flattened to a string, because the
|
|
387
|
+
* timeline's `ToolRow` renders an object input as a key/value list and a string
|
|
388
|
+
* as one blob — keeping the shape keeps the row readable.
|
|
389
|
+
*/
|
|
390
|
+
function redactStructure(value: unknown, depth = 0): unknown {
|
|
391
|
+
if (depth > 6) return REDACTION_MARK;
|
|
392
|
+
if (typeof value === "string") {
|
|
393
|
+
return redactThenTruncate(value, MAX_INPUT_STRING_CHARS);
|
|
394
|
+
}
|
|
395
|
+
if (Array.isArray(value)) {
|
|
396
|
+
// A long array is a payload risk of its own; cap the element count too.
|
|
397
|
+
return value.slice(0, 20).map((item) => redactStructure(item, depth + 1));
|
|
398
|
+
}
|
|
399
|
+
if (value !== null && typeof value === "object") {
|
|
400
|
+
const out: Record<string, unknown> = {};
|
|
401
|
+
for (const [key, item] of Object.entries(
|
|
402
|
+
value as Record<string, unknown>,
|
|
403
|
+
)) {
|
|
404
|
+
out[key] = redactStructure(item, depth + 1);
|
|
405
|
+
}
|
|
406
|
+
return out;
|
|
407
|
+
}
|
|
408
|
+
return value;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export interface RedactedToolCall {
|
|
412
|
+
/** What may be sent as `payload.input`, or `undefined` when withheld. */
|
|
413
|
+
input?: unknown;
|
|
414
|
+
/** What may be sent as `payload.output`, or `undefined` when withheld. */
|
|
415
|
+
output?: string;
|
|
416
|
+
/** Set when rule 1 fired; surfaced on the event so the row explains itself. */
|
|
417
|
+
withheld?: WithholdReason;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Apply all three rules to one tool call.
|
|
422
|
+
*
|
|
423
|
+
* Returns the pair that may be published. A withheld call keeps neither half:
|
|
424
|
+
* a `Read` of a `.env` withholds the output for the obvious reason, and the
|
|
425
|
+
* input for a less obvious one — the PATH of a credential file is itself worth
|
|
426
|
+
* withholding, since it tells a reader exactly where to go looking.
|
|
427
|
+
*/
|
|
428
|
+
export function redactToolCall(args: {
|
|
429
|
+
input?: unknown;
|
|
430
|
+
output?: string;
|
|
431
|
+
}): RedactedToolCall {
|
|
432
|
+
const sensitive = sensitivePathsIn(args.input);
|
|
433
|
+
if (sensitive.length > 0) {
|
|
434
|
+
return { withheld: "sensitive-path" };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const result: RedactedToolCall = {};
|
|
438
|
+
|
|
439
|
+
if (args.input !== undefined) {
|
|
440
|
+
let input = redactStructure(args.input);
|
|
441
|
+
// A structure can still be huge in aggregate even with every leaf capped.
|
|
442
|
+
// Fall back to a truncated serialization rather than shipping it.
|
|
443
|
+
let serialized: string;
|
|
444
|
+
try {
|
|
445
|
+
serialized = JSON.stringify(input) ?? "";
|
|
446
|
+
} catch {
|
|
447
|
+
serialized = "";
|
|
448
|
+
input = REDACTION_MARK;
|
|
449
|
+
}
|
|
450
|
+
if (serialized.length > MAX_INPUT_CHARS) {
|
|
451
|
+
input = truncate(serialized, MAX_INPUT_CHARS);
|
|
452
|
+
}
|
|
453
|
+
result.input = input;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (typeof args.output === "string" && args.output.length > 0) {
|
|
457
|
+
result.output = redactThenTruncate(args.output, MAX_OUTPUT_CHARS);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return result;
|
|
461
|
+
}
|