@aihq/harness 0.2.0-rc.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 +201 -0
- package/README.md +302 -0
- package/dist/chunk-S7XFTZJW.js +25876 -0
- package/dist/chunk-S7XFTZJW.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +12 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +706 -0
- package/dist/index.js +131 -0
- package/dist/index.js.map +1 -0
- package/package.json +69 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
type Posture = "vibe" | "team" | "enterprise";
|
|
5
|
+
type PostureSource = "flag" | "marker" | "env" | "default" | "org-floor";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The single external-process seam for the whole harness. PowerShell, nvidia-smi,
|
|
9
|
+
* curl, gitleaks, docker — every subprocess goes through a {@link Runner}. Tests
|
|
10
|
+
* inject a fake so no unit test ever spawns a real process or touches the network.
|
|
11
|
+
*/
|
|
12
|
+
interface RunResult {
|
|
13
|
+
/** Process exit code; null when terminated by signal. */
|
|
14
|
+
code: number | null;
|
|
15
|
+
stdout: string;
|
|
16
|
+
stderr: string;
|
|
17
|
+
/** True when the executable could not be found / spawned (ENOENT, timeout). */
|
|
18
|
+
spawnError?: boolean;
|
|
19
|
+
}
|
|
20
|
+
interface RunOptions {
|
|
21
|
+
input?: string;
|
|
22
|
+
cwd?: string;
|
|
23
|
+
env?: NodeJS.ProcessEnv;
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
}
|
|
26
|
+
type Runner = (argv: string[], opts?: RunOptions) => Promise<RunResult>;
|
|
27
|
+
/**
|
|
28
|
+
* Default runner backed by `child_process.execFile`. Never rejects on non-zero
|
|
29
|
+
* exit — it resolves a {@link RunResult} so callers branch on `code`/`spawnError`
|
|
30
|
+
* instead of try/catch. `argv[0]` is the executable; remaining items are args
|
|
31
|
+
* (no shell, so no quoting/injection surface).
|
|
32
|
+
*/
|
|
33
|
+
declare const defaultRunner: Runner;
|
|
34
|
+
/**
|
|
35
|
+
* Build a fake runner for tests. The handler maps an argv to a partial result;
|
|
36
|
+
* returning `undefined` yields a clean exit-0 with empty output.
|
|
37
|
+
*/
|
|
38
|
+
declare function fakeRunner(handler: (argv: string[], opts?: RunOptions) => Partial<RunResult> | undefined): Runner;
|
|
39
|
+
/** A runner that fails as if no executable exists — for "tool absent" test paths. */
|
|
40
|
+
declare const missingToolRunner: Runner;
|
|
41
|
+
|
|
42
|
+
type Platform = "windows" | "darwin" | "linux";
|
|
43
|
+
type GpuVendor = "nvidia" | "apple" | "amd" | "none";
|
|
44
|
+
type AccelBackend = "cuda" | "mps" | "rocm" | "cpu";
|
|
45
|
+
type EnvShell = "posix" | "powershell";
|
|
46
|
+
interface GpuInfo {
|
|
47
|
+
vendor: GpuVendor;
|
|
48
|
+
backend: AccelBackend;
|
|
49
|
+
/** Total VRAM in GB; 0 when unknown or no discrete GPU. */
|
|
50
|
+
vramGb: number;
|
|
51
|
+
name?: string;
|
|
52
|
+
}
|
|
53
|
+
interface VdiInfo {
|
|
54
|
+
isVdi: boolean;
|
|
55
|
+
/** The signal that matched, or why none did. */
|
|
56
|
+
reason: string;
|
|
57
|
+
kind?: "citrix" | "workspaces" | "res" | "rdp" | "generic";
|
|
58
|
+
}
|
|
59
|
+
interface CertEntry {
|
|
60
|
+
subject: string;
|
|
61
|
+
/** PEM-encoded certificate (BEGIN/END CERTIFICATE). */
|
|
62
|
+
pem: string;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* OS-specific behaviour behind one interface. Only the adapter matching the host
|
|
66
|
+
* is `verified` (smoke-tested on real metal); the others are implemented and
|
|
67
|
+
* unit-tested against captured fixture output but flagged unverified. Every
|
|
68
|
+
* method that shells out does so through the injected {@link Runner}.
|
|
69
|
+
*/
|
|
70
|
+
interface HostAdapter {
|
|
71
|
+
readonly platform: Platform;
|
|
72
|
+
readonly verified: boolean;
|
|
73
|
+
/** Corporate root CAs whose subject contains `pattern`, from the OS trust store. */
|
|
74
|
+
trustStoreCerts(pattern: string): Promise<CertEntry[]>;
|
|
75
|
+
/** The argv that would restrict `path` to the current user (icacls/chmod). Not executed here. */
|
|
76
|
+
lockDownFileArgv(path: string): string[];
|
|
77
|
+
/** The argv that creates a directory symlink/junction at `linkPath` → `targetPath`. */
|
|
78
|
+
symlinkDirArgv(linkPath: string, targetPath: string): string[];
|
|
79
|
+
cpuPhysicalCores(): Promise<number>;
|
|
80
|
+
totalRamGb(): Promise<number>;
|
|
81
|
+
gpu(): Promise<GpuInfo>;
|
|
82
|
+
detectVdi(): VdiInfo;
|
|
83
|
+
/** Local, non-synced scratch root for caches/SQLite on this host. */
|
|
84
|
+
scratchDir(user: string): string;
|
|
85
|
+
/** Shell profile file(s) where env exports belong. */
|
|
86
|
+
shellProfilePaths(): string[];
|
|
87
|
+
envShell(): EnvShell;
|
|
88
|
+
/**
|
|
89
|
+
* argv that persists a user-level env var SESSION-INDEPENDENTLY — i.e. where
|
|
90
|
+
* GUI-launched apps (Kiro, Claude Desktop, an IDE) inherit it, not just new
|
|
91
|
+
* shells. On Windows that is the per-user registry environment
|
|
92
|
+
* (`[Environment]::SetEnvironmentVariable(k,v,'User')`); on POSIX the durable
|
|
93
|
+
* seam is already the shell-profile `envblock`, so this returns `[]` (the
|
|
94
|
+
* caller emits no exec). A local mutation only — never contacts a remote.
|
|
95
|
+
*/
|
|
96
|
+
persistentEnvArgv(key: string, value: string): string[];
|
|
97
|
+
/**
|
|
98
|
+
* Absolute path to npm's `npm-cli.js` relative to the running Node binary, used
|
|
99
|
+
* to compose the doc'd npm self-heal (`node <npm-cli.js> install -g npm`).
|
|
100
|
+
* `undefined` when it cannot be located (npm not installed alongside Node).
|
|
101
|
+
*/
|
|
102
|
+
npmCliPath(): string | undefined;
|
|
103
|
+
/**
|
|
104
|
+
* argv for a read-only TLS reachability probe of `url`. Exit 0 = handshake OK;
|
|
105
|
+
* a non-zero exit = TLS/proxy failure; a spawn error (tool absent) lets the
|
|
106
|
+
* caller `skip`. Never mutates; the URL is a trusted module constant.
|
|
107
|
+
*/
|
|
108
|
+
tlsProbeArgv(url: string): string[];
|
|
109
|
+
}
|
|
110
|
+
/** Construction shape shared by the concrete adapters. */
|
|
111
|
+
type AdapterFactory = (run: Runner, env: NodeJS.ProcessEnv) => HostAdapter;
|
|
112
|
+
/** Wrap raw base64 DER into a PEM certificate block with 64-char lines. */
|
|
113
|
+
declare function derBase64ToPem(base64: string): string;
|
|
114
|
+
/** Validate a CA subject-match pattern (used in shell commands). Conservative allowlist. */
|
|
115
|
+
declare function safeCaPattern(pattern: string): string;
|
|
116
|
+
/**
|
|
117
|
+
* Cross-platform, env-based VDI signals shared by every host adapter, checked
|
|
118
|
+
* before the per-OS heuristics:
|
|
119
|
+
* - `AIH_VDI_KIND=<citrix|workspaces|res|rdp|generic>` lets fleet imaging pin the
|
|
120
|
+
* platform deterministically — the only reliable way to flag Amazon WorkSpaces
|
|
121
|
+
* or AVD, which expose no dependable env marker (this is what finally wires the
|
|
122
|
+
* `workspaces` kind into a reachable code path);
|
|
123
|
+
* - `AIH_FORCE_VDI=1` forces a generic VDI (back-compat; now honored on Windows
|
|
124
|
+
* too, which previously ignored it);
|
|
125
|
+
* - VMware / Omnissa Horizon exports `ViewClient_*` into the session, a genuine
|
|
126
|
+
* env-detectable marker.
|
|
127
|
+
* Returns undefined when nothing matches, so the caller's OS-specific heuristics run.
|
|
128
|
+
*/
|
|
129
|
+
declare function vdiFromEnv(env: NodeJS.ProcessEnv): VdiInfo | undefined;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The AI coding CLIs the harness can target. Capabilities that install agent
|
|
133
|
+
* tooling (ECC, Superpowers) and write IDE adapters use the user's selection
|
|
134
|
+
* (`--cli claude,codex` or `--all-tools`) so the harness only touches the tools
|
|
135
|
+
* the user actually runs. Names match each tool's own CLI / config conventions.
|
|
136
|
+
*/
|
|
137
|
+
declare const SUPPORTED_CLIS: readonly ["claude", "codex", "cursor", "antigravity", "gemini", "copilot", "windsurf", "opencode", "zed", "kimi", "kiro"];
|
|
138
|
+
type Cli = (typeof SUPPORTED_CLIS)[number];
|
|
139
|
+
|
|
140
|
+
interface EnvVar {
|
|
141
|
+
key: string;
|
|
142
|
+
value: string;
|
|
143
|
+
}
|
|
144
|
+
/** Format a single env assignment for the target shell. */
|
|
145
|
+
declare function formatExport(v: EnvVar, shell: EnvShell): string;
|
|
146
|
+
/**
|
|
147
|
+
* Insert or replace the aih-managed block for `scope` in a shell profile.
|
|
148
|
+
*
|
|
149
|
+
* Idempotent by construction: the region between the begin/end markers is
|
|
150
|
+
* replaced wholesale, so re-running with the same vars yields byte-identical
|
|
151
|
+
* output and lines outside the markers are never touched. Preserves the file's
|
|
152
|
+
* existing EOL style (CRLF vs LF).
|
|
153
|
+
*/
|
|
154
|
+
declare function upsertManagedBlock(existing: string, scope: string, vars: EnvVar[], shell: EnvShell): string;
|
|
155
|
+
/**
|
|
156
|
+
* Insert or replace the aih-managed `scope` block carrying arbitrary `body` text
|
|
157
|
+
* (the format-agnostic core of {@link upsertManagedBlock}). The `#`-comment markers
|
|
158
|
+
* are valid in any `#`-commented format — shell profiles AND TOML — so `aih mcp`
|
|
159
|
+
* reuses this to fold its `[mcp_servers.*]` tables into Codex's `~/.codex/config.toml`
|
|
160
|
+
* without clobbering the rest of the file. Idempotent: the region between the markers
|
|
161
|
+
* is replaced wholesale, content outside is untouched, and the file's EOL style
|
|
162
|
+
* (CRLF vs LF) is preserved.
|
|
163
|
+
*/
|
|
164
|
+
declare function upsertTextBlock(existing: string, scope: string, body: string): string;
|
|
165
|
+
/** Remove the managed block for `scope` if present (used by uninstall paths). */
|
|
166
|
+
declare function removeManagedBlock(existing: string, scope: string): string;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A minimal question/answer seam so interactive prompts stay testable: production
|
|
170
|
+
* code wires {@link makeReadlinePrompter}; tests inject a fake that returns canned
|
|
171
|
+
* answers. The harness stays non-interactive by default — a prompter is only wired
|
|
172
|
+
* when the user explicitly opts in (e.g. `--detect`) AND the session is a TTY.
|
|
173
|
+
*/
|
|
174
|
+
interface Prompter {
|
|
175
|
+
/** Print `question`, read one line, and return the trimmed answer ("" on bare Enter/EOF). */
|
|
176
|
+
ask(question: string): Promise<string>;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A single verification outcome produced by a probe action or `doctor`. */
|
|
180
|
+
type Verdict = "pass" | "fail" | "skip";
|
|
181
|
+
/**
|
|
182
|
+
* Closed taxonomy of routable verification outcomes. Each member maps 1:1 to a
|
|
183
|
+
* real `fail`/`skip` emitter (see docs/research/check-code-taxonomy-plan.md) so a
|
|
184
|
+
* consumer — support templates, run-ledger findings — can `switch` over it
|
|
185
|
+
* exhaustively rather than string-match `detail` (which rots on a reword). Keep it
|
|
186
|
+
* sealed: a new failure mode means a new member here PLUS the `code` set at the
|
|
187
|
+
* emitter; never derive a code by matching `detail`.
|
|
188
|
+
*/
|
|
189
|
+
type CheckCode = "env.node-runtime" | "env.git-missing" | "env.dev-tool-missing" | "env.tool-install-blocked" | "cert.ca-missing" | "tls.verify-failed" | "npm.runtime-broken" | "path.missing" | "mcp.blocked" | "mcp.uv-missing" | "mcp.config-missing" | "mcp.unvendored-offline" | "mcp.policy-denied" | "mcp.hardcoded-secret" | "mcp.allowlist-drift" | "cli.not-detected" | "cli.config-only" | "cli.bootloader-missing" | "cli.bootloader-drift" | "cli.wont-load" | "canon.router-missing" | "canon.context-dir-missing" | "canon.lint-failed" | "canon.adoptable" | "canon.cli-native-unmigrated" | "secrets.plaintext-detected" | "guardrails.gitleaks-missing" | "usage.no-data" | "scale.code-review-graph-missing" | "contract.path-unportable" | "contract.stale" | "org-policy.drift" | "report.context-over-budget" | "report.low-adoption" | "report.contract-untrue" | "trust.fetch-blocked" | "trust.detector-unavailable" | "trust.hidden-unicode" | "trust.prompt-injection" | "trust.source-changed" | "trust.auto-exec-hook" | "trust.dependency-confusion" | "trust.typosquat" | "trust.malicious-code" | "trust.source-drift" | "trust.unpinned-dependency" | "trust.untrusted-publisher" | "trust.unsigned-source";
|
|
190
|
+
interface Check {
|
|
191
|
+
name: string;
|
|
192
|
+
verdict: Verdict;
|
|
193
|
+
detail?: string;
|
|
194
|
+
/**
|
|
195
|
+
* Stable machine code for routing (support templates, run-ledger findings). Set
|
|
196
|
+
* ONLY on `fail`/`skip` emitters a consumer keys off — never on a `pass`, and
|
|
197
|
+
* never derived from `detail`. Absent ⇒ not yet ticket-routed. Optional by
|
|
198
|
+
* design, so a Check that omits it serializes byte-for-byte as before.
|
|
199
|
+
*/
|
|
200
|
+
code?: CheckCode;
|
|
201
|
+
/** Optional repo-relative artifact location for file-backed findings. */
|
|
202
|
+
location?: {
|
|
203
|
+
uri: string;
|
|
204
|
+
startLine?: number;
|
|
205
|
+
};
|
|
206
|
+
/** Optional stable fingerprint for code-scanning de-dupe. */
|
|
207
|
+
fingerprint?: string;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Accumulates {@link Check}s and renders a fail-closed report. `skip` never fails
|
|
211
|
+
* the run (used when a tool/daemon is absent); only `fail` flips the exit code.
|
|
212
|
+
*/
|
|
213
|
+
declare class VerificationReport {
|
|
214
|
+
readonly checks: Check[];
|
|
215
|
+
add(check: Check): this;
|
|
216
|
+
pass(name: string, detail?: string): this;
|
|
217
|
+
fail(name: string, detail?: string): this;
|
|
218
|
+
skip(name: string, detail?: string): this;
|
|
219
|
+
get ok(): boolean;
|
|
220
|
+
counts(): Record<Verdict, number>;
|
|
221
|
+
/** 0 when no check failed, 1 otherwise. */
|
|
222
|
+
exitCode(): number;
|
|
223
|
+
toJSON(): {
|
|
224
|
+
ok: boolean;
|
|
225
|
+
counts: Record<Verdict, number>;
|
|
226
|
+
checks: Check[];
|
|
227
|
+
};
|
|
228
|
+
summary(): string;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The harness never performs a remote mutation. Every unit of work is one of:
|
|
233
|
+
* - `write`: create/merge a local file (transactional, with backup);
|
|
234
|
+
* - `doc`: emit guidance / commands for a human (printed, or written to a
|
|
235
|
+
* doc file) — this is where cloud setup steps live, deliberately
|
|
236
|
+
* not run;
|
|
237
|
+
* - `probe`: a read-only verification that yields a {@link Check} under
|
|
238
|
+
* --verify;
|
|
239
|
+
* - `exec`: a LOCAL helper command run after writes under --apply (e.g.
|
|
240
|
+
* icacls/chmod to lock down a PEM, `mklink`/`ln` for a VDI
|
|
241
|
+
* junction, or a read-only quarantined tarball fetch) — it must
|
|
242
|
+
* never mutate a remote system;
|
|
243
|
+
* - `envblock`: upsert an aih-managed env block (one `scope`) into a shell
|
|
244
|
+
* profile; multiple scopes targeting the same file compose
|
|
245
|
+
* instead of clobbering each other;
|
|
246
|
+
* - `digest`: a read-only computed result printed verbatim (an analytics
|
|
247
|
+
* report / roll-up) plus optional structured `data` echoed into
|
|
248
|
+
* `--json` — mutates nothing, never contacts a remote system.
|
|
249
|
+
* Because no action kind can mutate a remote system, an autonomous run cannot
|
|
250
|
+
* "fake provisioning" — the capability simply does not exist.
|
|
251
|
+
*/
|
|
252
|
+
type ActionKind = "write" | "probe" | "doc" | "exec" | "envblock" | "digest";
|
|
253
|
+
interface WriteAction {
|
|
254
|
+
kind: "write";
|
|
255
|
+
path: string;
|
|
256
|
+
describe: string;
|
|
257
|
+
/** Raw file contents (for text files). */
|
|
258
|
+
contents?: string;
|
|
259
|
+
/** Structured value (for JSON files); enables `merge`. */
|
|
260
|
+
json?: unknown;
|
|
261
|
+
/** Deep-merge `json` onto an existing file instead of overwriting. */
|
|
262
|
+
merge?: boolean;
|
|
263
|
+
/** POSIX file mode, e.g. 0o755 for hooks. */
|
|
264
|
+
mode?: number;
|
|
265
|
+
/** Write only if the file is absent; never overwrite (user-owned seed files). */
|
|
266
|
+
once?: boolean;
|
|
267
|
+
/**
|
|
268
|
+
* Allow this write to land OUTSIDE the target root (home/system files: PEM
|
|
269
|
+
* bundles, shell profiles, VDI redirects). Repo-scoped writes leave this unset
|
|
270
|
+
* and the executor fails closed if their resolved path escapes the root.
|
|
271
|
+
*/
|
|
272
|
+
external?: boolean;
|
|
273
|
+
}
|
|
274
|
+
interface DocAction {
|
|
275
|
+
kind: "doc";
|
|
276
|
+
describe: string;
|
|
277
|
+
text: string;
|
|
278
|
+
/** When set, the guidance is also written to this doc file. */
|
|
279
|
+
path?: string;
|
|
280
|
+
}
|
|
281
|
+
interface ProbeAction {
|
|
282
|
+
kind: "probe";
|
|
283
|
+
describe: string;
|
|
284
|
+
run: (ctx: PlanContext) => Promise<Check> | Check;
|
|
285
|
+
/** Dynamic scans may expand to several 1:1 checks after a prior exec action. */
|
|
286
|
+
runMany?: (ctx: PlanContext) => Promise<Check[]> | Check[];
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* A LOCAL helper command run after writes under `--apply` (e.g. icacls/chmod to
|
|
290
|
+
* lock down a PEM, `mklink /J` for a VDI junction, `update-ca-certificates`, or
|
|
291
|
+
* a read-only quarantined tarball fetch). It must never mutate a remote system —
|
|
292
|
+
* that is what keeps the "no faked provisioning" guarantee intact.
|
|
293
|
+
*/
|
|
294
|
+
interface ExecAction {
|
|
295
|
+
kind: "exec";
|
|
296
|
+
describe: string;
|
|
297
|
+
argv: string[];
|
|
298
|
+
/** Optional working directory for local, quarantined helper commands. */
|
|
299
|
+
cwd?: string;
|
|
300
|
+
/** Optional scrubbed environment for local helper commands. */
|
|
301
|
+
env?: NodeJS.ProcessEnv;
|
|
302
|
+
/** Optional timeout override for long-but-bounded local helpers. */
|
|
303
|
+
timeoutMs?: number;
|
|
304
|
+
/** Optional verification check to emit when the command exits non-zero. */
|
|
305
|
+
failureCheck?: Check | ((result: RunResult) => Check);
|
|
306
|
+
/** Skip follow-on probes when this command fails. */
|
|
307
|
+
blockProbesOnFailure?: boolean;
|
|
308
|
+
/** Continue the plan even if the command exits non-zero. */
|
|
309
|
+
allowFailure?: boolean;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Upsert an aih-managed env block (one `scope`) into a shell profile. Unlike a
|
|
313
|
+
* plain `write`, multiple `envblock` actions targeting the SAME file COMPOSE:
|
|
314
|
+
* the executor folds every scope's block into the file in order (starting from
|
|
315
|
+
* the on-disk content), so e.g. `bootstrap` can layer certs + hardware + vdi +
|
|
316
|
+
* telemetry blocks into one profile without any of them clobbering the others.
|
|
317
|
+
*/
|
|
318
|
+
interface EnvBlockAction {
|
|
319
|
+
kind: "envblock";
|
|
320
|
+
path: string;
|
|
321
|
+
scope: string;
|
|
322
|
+
shell: EnvShell;
|
|
323
|
+
vars: EnvVar[];
|
|
324
|
+
describe: string;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* A read-only ANALYSIS result surfaced to the operator. Unlike {@link DocAction}
|
|
328
|
+
* (whose body only ever lands in a file), a digest's `text` is printed verbatim
|
|
329
|
+
* beneath its headline by the summary, and its optional `data` rides into
|
|
330
|
+
* `--json` — the shape analytics reports and inventory roll-ups need. It mutates
|
|
331
|
+
* nothing and never contacts a remote system.
|
|
332
|
+
*/
|
|
333
|
+
interface DigestAction {
|
|
334
|
+
kind: "digest";
|
|
335
|
+
describe: string;
|
|
336
|
+
/** Report body, printed verbatim beneath the headline in text mode. */
|
|
337
|
+
text?: string;
|
|
338
|
+
/** Machine-readable payload echoed into `--json` output. */
|
|
339
|
+
data?: unknown;
|
|
340
|
+
/** Optional late-bound digest for analyses that depend on earlier exec/probe actions. */
|
|
341
|
+
run?: (ctx: PlanContext) => Promise<string | {
|
|
342
|
+
text: string;
|
|
343
|
+
data?: unknown;
|
|
344
|
+
}> | string | {
|
|
345
|
+
text: string;
|
|
346
|
+
data?: unknown;
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
type Action = WriteAction | DocAction | ProbeAction | ExecAction | EnvBlockAction | DigestAction;
|
|
350
|
+
interface Plan {
|
|
351
|
+
capability: string;
|
|
352
|
+
actions: Action[];
|
|
353
|
+
}
|
|
354
|
+
/** Everything a capability needs to compute (and a runner to execute) its plan. */
|
|
355
|
+
interface PlanContext {
|
|
356
|
+
/** Target repository / workstation root. */
|
|
357
|
+
root: string;
|
|
358
|
+
/** Canonical context directory name (default ".ai-context"). */
|
|
359
|
+
contextDir: string;
|
|
360
|
+
/** Harness-wide governance posture dial, resolved by the shared ladder. */
|
|
361
|
+
posture?: Posture;
|
|
362
|
+
/** Where the active posture came from (flag/marker/env/default/org floor). */
|
|
363
|
+
postureSource?: PostureSource;
|
|
364
|
+
/** When false (default), the plan is computed but nothing is written. */
|
|
365
|
+
apply: boolean;
|
|
366
|
+
/** When true, probe actions run and contribute to the verification report. */
|
|
367
|
+
verify: boolean;
|
|
368
|
+
json: boolean;
|
|
369
|
+
run: Runner;
|
|
370
|
+
host: HostAdapter;
|
|
371
|
+
env: NodeJS.ProcessEnv;
|
|
372
|
+
/**
|
|
373
|
+
* Interactive prompt seam. Present only when the user opted into an interactive
|
|
374
|
+
* flow (e.g. `--detect`) in a TTY; undefined keeps the harness non-interactive.
|
|
375
|
+
*/
|
|
376
|
+
prompter?: Prompter;
|
|
377
|
+
/**
|
|
378
|
+
* The resolved CLI target set, injected by an orchestrator (`aih init`) that
|
|
379
|
+
* resolves `--detect`/`--cli` ONCE and threads the result into every phase. A
|
|
380
|
+
* tool-specific phase emits a tool's files only when that tool is targeted (see
|
|
381
|
+
* {@link isTargeted}). Undefined when a leaf command runs standalone — it then
|
|
382
|
+
* keeps its single-tool identity (`aih profile` is the Cursor profiler, `aih
|
|
383
|
+
* secrets` the Claude secrets guard) and always writes.
|
|
384
|
+
*/
|
|
385
|
+
targets?: Cli[];
|
|
386
|
+
/** Capability-specific options parsed from the CLI. */
|
|
387
|
+
options: Record<string, unknown>;
|
|
388
|
+
}
|
|
389
|
+
type PlanFn = (ctx: PlanContext) => Plan | Promise<Plan>;
|
|
390
|
+
interface CommandOption {
|
|
391
|
+
flags: string;
|
|
392
|
+
description: string;
|
|
393
|
+
default?: string | boolean;
|
|
394
|
+
}
|
|
395
|
+
interface CommandSpec {
|
|
396
|
+
name: string;
|
|
397
|
+
summary: string;
|
|
398
|
+
options?: CommandOption[];
|
|
399
|
+
plan: PlanFn;
|
|
400
|
+
/** Read-only commands (doctor/status) skip the apply path entirely. */
|
|
401
|
+
readOnly?: boolean;
|
|
402
|
+
/**
|
|
403
|
+
* Force `verify` on every run so the capability's probes always populate the
|
|
404
|
+
* verification report — i.e. it DIAGNOSES by default (like `doctor`) yet still
|
|
405
|
+
* mutates under `--apply` (unlike `readOnly`). `heal` uses this so a bare
|
|
406
|
+
* `aih heal` surfaces the health report and a non-zero exit when broken.
|
|
407
|
+
*/
|
|
408
|
+
alwaysVerify?: boolean;
|
|
409
|
+
/**
|
|
410
|
+
* Exempt from the dirty-worktree `--apply` preflight. For pure-analytics commands
|
|
411
|
+
* (`aih report`) whose only writes are gitignored OUTPUT artifacts (the `.aih/`
|
|
412
|
+
* report file + its ignore rule) — those never clobber uncommitted work, so
|
|
413
|
+
* blocking the report on a dirty tree is wrong.
|
|
414
|
+
*/
|
|
415
|
+
skipWorktreeGate?: boolean;
|
|
416
|
+
}
|
|
417
|
+
declare function writeText(path: string, contents: string, describe: string, opts?: {
|
|
418
|
+
mode?: number;
|
|
419
|
+
once?: boolean;
|
|
420
|
+
external?: boolean;
|
|
421
|
+
}): WriteAction;
|
|
422
|
+
declare function writeJson(path: string, value: unknown, describe: string, opts?: {
|
|
423
|
+
merge?: boolean;
|
|
424
|
+
external?: boolean;
|
|
425
|
+
}): WriteAction;
|
|
426
|
+
declare function doc(describe: string, text: string, path?: string): DocAction;
|
|
427
|
+
declare function digest(describe: string, text: string, data?: unknown): DigestAction;
|
|
428
|
+
declare function dynamicDigest(describe: string, run: NonNullable<DigestAction["run"]>): DigestAction;
|
|
429
|
+
declare function probe(describe: string, run: ProbeAction["run"]): ProbeAction;
|
|
430
|
+
declare function probeMany(describe: string, runMany: NonNullable<ProbeAction["runMany"]>): ProbeAction;
|
|
431
|
+
declare function exec(describe: string, argv: string[], opts?: {
|
|
432
|
+
allowFailure?: boolean;
|
|
433
|
+
cwd?: string;
|
|
434
|
+
env?: NodeJS.ProcessEnv;
|
|
435
|
+
timeoutMs?: number;
|
|
436
|
+
failureCheck?: ExecAction["failureCheck"];
|
|
437
|
+
blockProbesOnFailure?: boolean;
|
|
438
|
+
}): ExecAction;
|
|
439
|
+
declare function envBlock(path: string, scope: string, shell: EnvShell, vars: EnvVar[], describe: string): EnvBlockAction;
|
|
440
|
+
declare function plan(capability: string, ...actions: Action[]): Plan;
|
|
441
|
+
|
|
442
|
+
/** Capability commands (repo/workstation mutators), dry-run by default. */
|
|
443
|
+
declare const CAPABILITIES: CommandSpec[];
|
|
444
|
+
/** Read-only commands (always safe). */
|
|
445
|
+
declare const READONLY: CommandSpec[];
|
|
446
|
+
declare const ALL_COMMANDS: CommandSpec[];
|
|
447
|
+
declare function registerCommands(program: Command): void;
|
|
448
|
+
|
|
449
|
+
/** Resolved runtime settings (env defaults overlaid with CLI flags). */
|
|
450
|
+
interface Settings {
|
|
451
|
+
apply: boolean;
|
|
452
|
+
verify: boolean;
|
|
453
|
+
json: boolean;
|
|
454
|
+
contextDir: string;
|
|
455
|
+
root: string;
|
|
456
|
+
caPattern: string;
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* A canonical context-dir name: a simple, repo-relative path with no traversal.
|
|
460
|
+
* Exported so {@link readAihConfig} validates the committed `.aih-config.json`
|
|
461
|
+
* marker against the SAME constraints settings enforce — a marker can never carry
|
|
462
|
+
* a dir that a flag/env value would have rejected.
|
|
463
|
+
*/
|
|
464
|
+
declare const ContextDir: z.ZodString;
|
|
465
|
+
/**
|
|
466
|
+
* Resolve settings fail-closed: env provides defaults (`AIH_*`), `overrides`
|
|
467
|
+
* (CLI flags) win, and any malformed value throws {@link SettingsError} before a
|
|
468
|
+
* command runs. Dry-run (`apply=false`) is the safe default.
|
|
469
|
+
*/
|
|
470
|
+
declare function loadSettings(env: NodeJS.ProcessEnv, overrides?: Partial<Settings>): Settings;
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Typed error hierarchy for the harness. Every error carries a stable machine
|
|
474
|
+
* `code` so `--json` output and `doctor` reports stay parseable across versions.
|
|
475
|
+
*/
|
|
476
|
+
declare class AihError extends Error {
|
|
477
|
+
readonly code: string;
|
|
478
|
+
constructor(message: string, code?: string);
|
|
479
|
+
}
|
|
480
|
+
/** Invalid/contradictory configuration (env or CLI). Fail-closed. */
|
|
481
|
+
declare class SettingsError extends AihError {
|
|
482
|
+
constructor(message: string);
|
|
483
|
+
}
|
|
484
|
+
/** A host/platform probe could not be satisfied on this OS. */
|
|
485
|
+
declare class PlatformError extends AihError {
|
|
486
|
+
constructor(message: string);
|
|
487
|
+
}
|
|
488
|
+
/** A staged filesystem transaction failed (and was rolled back). */
|
|
489
|
+
declare class FsTxnError extends AihError {
|
|
490
|
+
constructor(message: string);
|
|
491
|
+
}
|
|
492
|
+
/** A verification probe failed in a way that should halt the run. */
|
|
493
|
+
declare class VerificationError extends AihError {
|
|
494
|
+
constructor(message: string);
|
|
495
|
+
}
|
|
496
|
+
/** Capability not yet implemented (foundation stub). */
|
|
497
|
+
declare class NotImplementedError extends AihError {
|
|
498
|
+
constructor(message: string);
|
|
499
|
+
}
|
|
500
|
+
/** Existing config could not be parsed for a merge — fail closed, never partial-merge. */
|
|
501
|
+
declare class MergeError extends AihError {
|
|
502
|
+
constructor(message: string);
|
|
503
|
+
}
|
|
504
|
+
/** An action path escaped its intended root (path-containment violation). Fail-closed. */
|
|
505
|
+
declare class PathContainmentError extends AihError {
|
|
506
|
+
constructor(message: string);
|
|
507
|
+
}
|
|
508
|
+
/** An `--apply` was attempted on a dirty git worktree without `--force`. Fail-closed. */
|
|
509
|
+
declare class DirtyWorktreeError extends AihError {
|
|
510
|
+
constructor(message: string);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
interface WriteSummary {
|
|
514
|
+
path: string;
|
|
515
|
+
describe: string;
|
|
516
|
+
merged: boolean;
|
|
517
|
+
/**
|
|
518
|
+
* Effect relative to current disk state. `unchanged` writes are skipped (no
|
|
519
|
+
* backup); `kept` is a write-once file that already exists (left untouched).
|
|
520
|
+
*/
|
|
521
|
+
effect: "create" | "overwrite" | "merge" | "unchanged" | "kept";
|
|
522
|
+
}
|
|
523
|
+
interface PlanResult {
|
|
524
|
+
capability: string;
|
|
525
|
+
applied: boolean;
|
|
526
|
+
writes: WriteSummary[];
|
|
527
|
+
docs: {
|
|
528
|
+
describe: string;
|
|
529
|
+
path?: string;
|
|
530
|
+
}[];
|
|
531
|
+
probes: {
|
|
532
|
+
describe: string;
|
|
533
|
+
}[];
|
|
534
|
+
execs: {
|
|
535
|
+
describe: string;
|
|
536
|
+
argv: string[];
|
|
537
|
+
ran: boolean;
|
|
538
|
+
code?: number | null;
|
|
539
|
+
ok?: boolean;
|
|
540
|
+
}[];
|
|
541
|
+
/** Read-only computed reports surfaced verbatim (text) + machine-readable (`data`). */
|
|
542
|
+
digests: {
|
|
543
|
+
describe: string;
|
|
544
|
+
text: string;
|
|
545
|
+
data?: unknown;
|
|
546
|
+
}[];
|
|
547
|
+
backups: string[];
|
|
548
|
+
report?: VerificationReport;
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Write a single, explicitly-requested analysis artifact (e.g. a `--sarif` report)
|
|
552
|
+
* to a repo-contained path, transactionally. Returns the backups created (0 or 1).
|
|
553
|
+
*
|
|
554
|
+
* DESIGN — why this is NOT gated on `--apply`: the harness invariant "no writes
|
|
555
|
+
* without --apply" protects the user's MANAGED project surface (bootloaders,
|
|
556
|
+
* configs, the context dir) from being mutated without consent. A `--sarif` file
|
|
557
|
+
* is not part of that surface — it is a report OUTPUT the operator requested by
|
|
558
|
+
* naming its path on the command line, exactly like `report --out` or a test
|
|
559
|
+
* runner writing `junit.xml`. Naming the path IS the consent. Crucially, the
|
|
560
|
+
* primary use case — `aih bootstrap-ai --verify --sarif results.sarif` feeding
|
|
561
|
+
* GitHub code-scanning — runs the drift gate WITHOUT `--apply` (CI must not
|
|
562
|
+
* regenerate the repo it is gating); apply-gating the artifact would make the flag
|
|
563
|
+
* a no-op in exactly the scenario it exists for, or force `--apply` to also rewrite
|
|
564
|
+
* every bootloader. So the artifact is decoupled from the plan's apply gate — but
|
|
565
|
+
* NOT from its safety machinery: the path is still contained to `root`
|
|
566
|
+
* ({@link assertContained}) and an overwrite is still backed up to `*.aih.bak` via
|
|
567
|
+
* {@link FsTransaction}. Re-writing identical bytes is a no-op (no rewrite, no
|
|
568
|
+
* backup churn), matching {@link executePlan}'s idempotency contract.
|
|
569
|
+
*/
|
|
570
|
+
declare function writeArtifact(ctx: PlanContext, relPath: string, contents: string): string[];
|
|
571
|
+
/** Compute final file contents for a write action, applying JSON merge if requested. */
|
|
572
|
+
declare function resolveContents(action: WriteAction, absPath: string): string;
|
|
573
|
+
/**
|
|
574
|
+
* Execute a plan. In dry-run (`ctx.apply === false`) nothing is written — the
|
|
575
|
+
* result still reports exactly what would change. With `ctx.apply` writes are
|
|
576
|
+
* committed transactionally; with `ctx.verify` probe actions run and populate a
|
|
577
|
+
* {@link VerificationReport}.
|
|
578
|
+
*/
|
|
579
|
+
declare function executePlan(plan: Plan, ctx: PlanContext, opts?: {
|
|
580
|
+
skipWorktreeGate?: boolean;
|
|
581
|
+
}): Promise<PlanResult>;
|
|
582
|
+
/** Human-readable summary of a plan result (used when --json is off). */
|
|
583
|
+
declare function summarizeResult(result: PlanResult): string;
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Run a synchronous fs operation, retrying ONLY the transient Windows lock codes
|
|
587
|
+
* in {@link TRANSIENT_LOCK_CODES} with a short bounded backoff (~0.5s worst case).
|
|
588
|
+
* Any other error — `EEXIST` from an exclusive create, a genuine `EACCES` on a
|
|
589
|
+
* locked-down path that never clears — is re-thrown on its first occurrence, so
|
|
590
|
+
* this absorbs the sub-millisecond scanner window without ever masking a real
|
|
591
|
+
* failure. The retry preserves the caller's atomicity/rollback guarantees: it
|
|
592
|
+
* re-issues the same single syscall, nothing more.
|
|
593
|
+
*
|
|
594
|
+
* Exported for direct unit testing — the FS-level retry is exercised through the
|
|
595
|
+
* real filesystem elsewhere, but a transient lock cannot be reproduced on demand,
|
|
596
|
+
* so the retry/give-up/passthrough contract is pinned here.
|
|
597
|
+
*/
|
|
598
|
+
declare function retryTransient<T>(op: () => T): T;
|
|
599
|
+
interface StagedWrite {
|
|
600
|
+
path: string;
|
|
601
|
+
contents: string;
|
|
602
|
+
mode?: number;
|
|
603
|
+
}
|
|
604
|
+
interface FsTxnResult {
|
|
605
|
+
written: string[];
|
|
606
|
+
backups: string[];
|
|
607
|
+
}
|
|
608
|
+
/**
|
|
609
|
+
* Stages writes in memory and commits them atomically. Each existing target is
|
|
610
|
+
* first copied to `<path>.aih.bak`; new content is written to a temp file and
|
|
611
|
+
* `rename`d into place (atomic on the same volume). If any write throws, every
|
|
612
|
+
* write applied so far is rolled back (created files removed, overwritten files
|
|
613
|
+
* restored from their backup).
|
|
614
|
+
*/
|
|
615
|
+
declare class FsTransaction {
|
|
616
|
+
private staged;
|
|
617
|
+
stage(path: string, contents: string, mode?: number): void;
|
|
618
|
+
preview(): ReadonlyArray<StagedWrite>;
|
|
619
|
+
commit(): FsTxnResult;
|
|
620
|
+
}
|
|
621
|
+
/** Read a file's text, or `undefined` if it does not exist. */
|
|
622
|
+
declare function readIfExists(path: string): string | undefined;
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Parse JSON or JSONC text (tolerant of comments + trailing commas). Returns
|
|
626
|
+
* `undefined` for empty input. Throws {@link MergeError} on a genuine syntax
|
|
627
|
+
* error: `jsonc-parser` returns a PARTIAL value for malformed input (incomplete
|
|
628
|
+
* braces, trailing garbage), and merging onto a partial parse would silently
|
|
629
|
+
* drop the user's real config — so we fail closed and ask for a manual fix
|
|
630
|
+
* instead of overwriting from a half-read file.
|
|
631
|
+
*/
|
|
632
|
+
declare function parseJsoncText(text: string): unknown;
|
|
633
|
+
declare function isPlainObject(v: unknown): v is Record<string, unknown>;
|
|
634
|
+
/**
|
|
635
|
+
* Deep-merge `incoming` (harness-generated) onto `base` (existing user config),
|
|
636
|
+
* preserving every key that exists only in `base`. Objects merge recursively;
|
|
637
|
+
* primitive arrays become a deduped union (base order first) so things like
|
|
638
|
+
* `permissions.deny` accumulate instead of clobbering; for any other type
|
|
639
|
+
* mismatch, `incoming` wins.
|
|
640
|
+
*/
|
|
641
|
+
declare function deepMerge(base: unknown, incoming: unknown): unknown;
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Deterministic string-building helpers shared by capability templates. All
|
|
645
|
+
* generated files flow through these so golden-file tests stay stable: no dates,
|
|
646
|
+
* no random ordering, single trailing newline.
|
|
647
|
+
*/
|
|
648
|
+
/**
|
|
649
|
+
* Strip trailing newlines in linear time. The idiomatic `/\n+$/` is a
|
|
650
|
+
* polynomial-ReDoS footgun (CodeQL `js/polynomial-redos`): on a long run of
|
|
651
|
+
* newlines followed by a non-newline a backtracking engine retries the run from
|
|
652
|
+
* every start position — O(n²). A reverse scan is provably O(n) and byte-for-byte
|
|
653
|
+
* identical (only `\n` (U+000A) is stripped, exactly as the old regex did, so
|
|
654
|
+
* `\r` in a `\r\n` sequence is preserved either way).
|
|
655
|
+
*/
|
|
656
|
+
declare function stripTrailingNewlines(text: string): string;
|
|
657
|
+
/** Join parts (strings or string arrays) with newlines; exactly one trailing newline. */
|
|
658
|
+
declare function lines(...parts: Array<string | string[]>): string;
|
|
659
|
+
/** Indent every non-empty line of `text` by `n` spaces. */
|
|
660
|
+
declare function indent(text: string, n?: number): string;
|
|
661
|
+
/** Render a YAML frontmatter block from ordered key/value pairs (insertion order). */
|
|
662
|
+
declare function frontmatter(fields: Record<string, string | boolean | number | string[]>): string;
|
|
663
|
+
/** Stable 2-space JSON with a trailing newline (insertion order preserved). */
|
|
664
|
+
declare function jsonFile(value: unknown): string;
|
|
665
|
+
/** Ensure exactly one trailing newline. */
|
|
666
|
+
declare function ensureTrailingNewline(text: string): string;
|
|
667
|
+
/** Marker that opens an aih-managed region (comment syntax works in sh + PowerShell). */
|
|
668
|
+
declare function beginMarker(scope: string): string;
|
|
669
|
+
/** Marker that closes an aih-managed region. */
|
|
670
|
+
declare function endMarker(scope: string): string;
|
|
671
|
+
/** Wrap `body` in begin/end markers for in-place regeneration. */
|
|
672
|
+
declare function managedBlock(scope: string, body: string): string;
|
|
673
|
+
|
|
674
|
+
/** Resolve the effective platform, honoring the `AIH_PLATFORM` test override. */
|
|
675
|
+
declare function resolvePlatform(env?: NodeJS.ProcessEnv): Platform;
|
|
676
|
+
interface HostAdapterOptions {
|
|
677
|
+
platform?: Platform;
|
|
678
|
+
run?: Runner;
|
|
679
|
+
env?: NodeJS.ProcessEnv;
|
|
680
|
+
}
|
|
681
|
+
/** Construct the host adapter for this (or an overridden) platform. */
|
|
682
|
+
declare function makeHostAdapter(opts?: HostAdapterOptions): HostAdapter;
|
|
683
|
+
|
|
684
|
+
/** First integer anywhere in `stdout` (handles trailing newlines/whitespace). */
|
|
685
|
+
declare function parseFirstInt(stdout: string): number | undefined;
|
|
686
|
+
/** Parse `nvidia-smi --query-gpu=memory.total,name --format=csv,noheader,nounits`. */
|
|
687
|
+
declare function parseNvidiaSmi(stdout: string): GpuInfo;
|
|
688
|
+
/** Parse tab-separated `base64<TAB>subject` lines (Windows cert export). */
|
|
689
|
+
declare function parseCertLines(stdout: string): CertEntry[];
|
|
690
|
+
/**
|
|
691
|
+
* Extract PEM certificate blocks from `security`/openssl `-p` style output.
|
|
692
|
+
*
|
|
693
|
+
* A linear `indexOf` walk rather than `/BEGIN[\s\S]*?END/g`: that lazy match
|
|
694
|
+
* between two literal anchors is a polynomial-ReDoS footgun (CodeQL
|
|
695
|
+
* `js/polynomial-redos`) — on output with many `BEGIN` markers and no closing
|
|
696
|
+
* `END`, the engine rescans to end from every `BEGIN`, O(n²). The walk finds
|
|
697
|
+
* each `BEGIN` then its nearest following `END` (the same blocks the lazy regex
|
|
698
|
+
* matched, in order) with non-overlapping O(n) scans.
|
|
699
|
+
*/
|
|
700
|
+
declare function parsePemBlocks(stdout: string, subject?: string): CertEntry[];
|
|
701
|
+
|
|
702
|
+
declare const VERSION = "0.1.0";
|
|
703
|
+
/** Build the configured commander program. Imported by both the CLI entry and tests. */
|
|
704
|
+
declare function buildProgram(): Command;
|
|
705
|
+
|
|
706
|
+
export { ALL_COMMANDS, type AccelBackend, type Action, type ActionKind, type AdapterFactory, AihError, CAPABILITIES, type CertEntry, type Check, type CheckCode, type CommandOption, type CommandSpec, ContextDir, type DigestAction, DirtyWorktreeError, type DocAction, type EnvBlockAction, type EnvShell, type EnvVar, type ExecAction, FsTransaction, FsTxnError, type FsTxnResult, type GpuInfo, type GpuVendor, type HostAdapter, type HostAdapterOptions, MergeError, NotImplementedError, PathContainmentError, type Plan, type PlanContext, type PlanFn, type PlanResult, type Platform, PlatformError, type ProbeAction, READONLY, type RunOptions, type RunResult, type Runner, type Settings, SettingsError, VERSION, type VdiInfo, type Verdict, VerificationError, VerificationReport, type WriteAction, type WriteSummary, beginMarker, buildProgram, deepMerge, defaultRunner, derBase64ToPem, digest, doc, dynamicDigest, endMarker, ensureTrailingNewline, envBlock, exec, executePlan, fakeRunner, formatExport, frontmatter, indent, isPlainObject, jsonFile, lines, loadSettings, makeHostAdapter, managedBlock, missingToolRunner, parseCertLines, parseFirstInt, parseJsoncText, parseNvidiaSmi, parsePemBlocks, plan, probe, probeMany, readIfExists, registerCommands, removeManagedBlock, resolveContents, resolvePlatform, retryTransient, safeCaPattern, stripTrailingNewlines, summarizeResult, upsertManagedBlock, upsertTextBlock, vdiFromEnv, writeArtifact, writeJson, writeText };
|