@gethmy/harness 1.0.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/src/runner.ts ADDED
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The runner module: build the launch for one stage's subagent. One interface, one
3
+ * implementation — seam ready, no registry.
4
+ *
5
+ * `cli.ts` is the production caller (Task 15): it builds the launch, hands
6
+ * `disallowedTools` to `SdkAgentRunner`, and turns this launch's env omissions
7
+ * into the DELETE the spawned child needs via {@link envKeysDroppedByLaunch}
8
+ * (an omission alone strips nothing — `spawnInGroup` rebuilds the child's
9
+ * environment from the motor's own `process.env`).
10
+ *
11
+ * An `implementer` subagent's launch carries two measures against the held
12
+ * oracle:
13
+ *
14
+ * 1. Every Harmony credential is stripped from its environment (below).
15
+ * 2. Its `disallowedTools` carries a scoped `Read` deny over the credential
16
+ * directory, `~/.harmony-mcp`. That path is resolved via `getConfigDir()`,
17
+ * imported from `@gethmy/mcp/src/config.js` (the same import specifier
18
+ * `packages/harmony-agent/src/config.ts` already uses); the backing
19
+ * implementation, `packages/mcp-server/src/config.ts`, is the source of
20
+ * truth for the path and for `getActiveCredential()`, which prefers
21
+ * `oauthAccessToken` then `apiKey`.
22
+ *
23
+ * Neither measure closes the hole, and this module does not claim it does.
24
+ * `cli.ts` passes no `allowedTools`, so `SDK_ALLOWED_TOOLS`'s defaults
25
+ * (sdk-agent-runner.ts) apply and the implementer keeps a wide retained
26
+ * surface:
27
+ *
28
+ * - `Bash` stays allowed — the stage still needs it to build and run tests —
29
+ * and the credential file is an ordinary readable file, so
30
+ * `cat ~/.harmony-mcp/config.json` still works from inside a Bash call.
31
+ * - `Grep` and `Glob` stay allowed too, and neither is covered by the deny
32
+ * above (which names only `Read`): both can read the credential file's
33
+ * content or prove its existence.
34
+ * - `mcp__harmony__*` stays allowed. The harmony MCP server resolves its own
35
+ * credential straight from the file — `getActiveCredential()` calls
36
+ * `loadConfig()` with no environment fallback
37
+ * (packages/mcp-server/src/config.ts) — so neither the env strip nor the
38
+ * Read deny can disarm it: a `Read`-tool deny governs the agent's own Read
39
+ * tool, not a separate process's filesystem access. No MCP tool serves an
40
+ * oracle today, which is the only reason this is not yet a live path to
41
+ * one.
42
+ * - `env` still carries `HOME`. It has to: that is what makes
43
+ * `~/.harmony-mcp` locatable at all, for `getConfigDir()` and for the
44
+ * subagent's own shell alike.
45
+ *
46
+ * So "only `author`/`reviewer` may hold Harmony credentials" (below) describes
47
+ * the env strip's one target — the HTTP API credential in the environment —
48
+ * and nothing wider: an implementer still reaches Harmony through
49
+ * `mcp__harmony__*`, authenticated by the MCP server's own file-based
50
+ * credential, untouched by anything here. The env strip and the Read deny
51
+ * raise the bar against a stray env-var read or a direct Read-tool call;
52
+ * neither is a sandbox boundary, and neither covers Grep, Glob, or the MCP
53
+ * surface.
54
+ *
55
+ * The real locks on an oracle READ live in oracle-core.ts, not here:
56
+ * `purpose === "gate_evaluation"`, the target stage declaring an
57
+ * `oracle_passed` gate, and — enforced by the route before either function
58
+ * runs — binding the caller to the card's active agent session. There is
59
+ * deliberately no role check on read (ruling 27): the stage whose gate reads
60
+ * the oracle back IS the implementer stage, so a role check there would
61
+ * refuse every legitimate read.
62
+ *
63
+ * The launch also tells the implementer nothing it does not need: no session
64
+ * id, no oracle path. `RoleLaunch` below has no field for either.
65
+ *
66
+ * Fail closed: an absent role, or one that does not normalize
67
+ * (`normalizeStageRole`), is treated like `implementer`.
68
+ */
69
+ import { getConfigDir } from "@gethmy/mcp/src/config.js";
70
+ import { normalizeStageRole, type PlaybookStageRole } from "@harmony/shared";
71
+
72
+ /**
73
+ * Environment keys that carry Harmony access. Add every new one here — a key that
74
+ * is not listed is a key that leaks.
75
+ */
76
+ export const HARMONY_CREDENTIAL_KEYS: readonly string[] = [
77
+ "HARMONY_API_KEY",
78
+ "HARMONY_API_URL",
79
+ "HARMONY_WORKSPACE_ID",
80
+ "SUPABASE_ANON_KEY",
81
+ "SUPABASE_SERVICE_ROLE_KEY",
82
+ "SUPABASE_URL",
83
+ ];
84
+
85
+ export interface RoleLaunch {
86
+ /**
87
+ * The normalized role (`normalizeStageRole` — see `buildRoleLaunch`), never
88
+ * the raw input. A forged or malformed role never reaches a consumer of this
89
+ * field, mirroring `getStageRole`'s contract in playbookStage.ts.
90
+ */
91
+ role: PlaybookStageRole | null;
92
+ prompt: string;
93
+ repoPath: string;
94
+ env: Record<string, string>;
95
+ /**
96
+ * `disallowedTools` entries in the SDK's array form (see sdk-agent-runner.ts:
97
+ * a disallow wins over an allow). Non-empty exactly when the role may not
98
+ * hold Harmony credentials, in which case it carries one scoped `Read` deny
99
+ * over the credential directory. Empty for `author`/`reviewer`, who read the
100
+ * credential legitimately. `cli.ts` hands this to `SdkAgentRunner`. See the
101
+ * module doc comment above for what this narrows and — just as important —
102
+ * what it does not: `Grep`, `Glob`, and `mcp__harmony__*` all stay reachable
103
+ * regardless of this field.
104
+ */
105
+ disallowedTools: string[];
106
+ }
107
+
108
+ /**
109
+ * Only `author` and `reviewer` may hold the Harmony HTTP-API credential in
110
+ * their launch environment. This governs the env strip only — it says nothing
111
+ * about `mcp__harmony__*` access, which every role keeps; see the module doc
112
+ * comment for what that means in practice for the implementer.
113
+ */
114
+ function mayHoldCredentials(role: PlaybookStageRole | null): boolean {
115
+ return role === "author" || role === "reviewer";
116
+ }
117
+
118
+ /**
119
+ * A scoped `Read` deny over the credential directory. A `//`-prefixed pattern
120
+ * anchors to an absolute filesystem path; a single leading slash would instead
121
+ * anchor to the launch's `repoPath` (the Agent SDK's permission-rule scoping for
122
+ * `disallowedTools`), which is not what we want since `~/.harmony-mcp` sits
123
+ * outside the worktree.
124
+ */
125
+ function credentialReadDeny(): string {
126
+ return `Read(/${getConfigDir()}/**)`;
127
+ }
128
+
129
+ export function buildRoleLaunch(args: {
130
+ role: PlaybookStageRole | null;
131
+ prompt: string;
132
+ repoPath: string;
133
+ parentEnv: Record<string, string | undefined>;
134
+ }): RoleLaunch {
135
+ // Defense in depth, not a replacement for the caller's own validation: a
136
+ // stage's `role` ultimately comes off opaque JSONB (`playbooks.steps`) with
137
+ // no server-side schema validation, so the `PlaybookStageRole | null` type
138
+ // above is a claim the caller makes, not a guarantee this function can trust.
139
+ // Re-normalizing here, the same way `getStageRole` does at the read boundary
140
+ // in playbookStage.ts, means a forged or mis-cased value (e.g. "Implementer"
141
+ // — the exact shape ruling 27 had to fix in oracle-core.ts) fails closed
142
+ // here too, independent of whether the caller already normalized it.
143
+ const role = normalizeStageRole(args.role);
144
+ const keep = mayHoldCredentials(role);
145
+ const env: Record<string, string> = {};
146
+
147
+ for (const [key, value] of Object.entries(args.parentEnv)) {
148
+ if (value === undefined) continue;
149
+ if (!keep && HARMONY_CREDENTIAL_KEYS.includes(key)) continue;
150
+ env[key] = value;
151
+ }
152
+
153
+ return {
154
+ role,
155
+ prompt: args.prompt,
156
+ repoPath: args.repoPath,
157
+ env,
158
+ disallowedTools: keep ? [] : [credentialReadDeny()],
159
+ };
160
+ }
161
+
162
+ /**
163
+ * The keys `buildRoleLaunch` DROPPED from `parentEnv` — the strip set
164
+ * `SdkRunnerConfig.stripEnvKeys` (sdk-agent-runner.ts) hands to `spawnInGroup`.
165
+ *
166
+ * Derived from the launch rather than re-listing the credential keys, so
167
+ * `buildRoleLaunch` above stays the single source of truth: a key added to
168
+ * {@link HARMONY_CREDENTIAL_KEYS} is stripped from the child process without a
169
+ * second list anywhere. A key whose parent value is `undefined` is not
170
+ * reported — it reaches no child either way.
171
+ *
172
+ * This function reports what the launch omitted; it does not itself remove
173
+ * anything. The deletion happens in `spawnInGroup`, and only a caller that
174
+ * passes this set to a runner gets it. See that function's doc comment for why
175
+ * a merge cannot substitute for the delete.
176
+ */
177
+ export function envKeysDroppedByLaunch(
178
+ parentEnv: Record<string, string | undefined>,
179
+ launch: Pick<RoleLaunch, "env">,
180
+ ): string[] {
181
+ return Object.keys(parentEnv).filter(
182
+ (key) => parentEnv[key] !== undefined && !Object.hasOwn(launch.env, key),
183
+ );
184
+ }