crewhaus 0.1.7 → 0.2.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/README.md +10 -3
- package/dist/advice-apply.d.ts +182 -0
- package/dist/advice-apply.js +286 -0
- package/dist/advise-rules.d.ts +348 -0
- package/dist/advise-rules.js +905 -0
- package/dist/alert-sink.d.ts +48 -0
- package/dist/alert-sink.js +86 -0
- package/dist/approval-gate.d.ts +127 -0
- package/dist/approval-gate.js +254 -0
- package/dist/audit-verify.d.ts +69 -0
- package/dist/audit-verify.js +97 -0
- package/dist/autodistill.d.ts +113 -0
- package/dist/autodistill.js +256 -0
- package/dist/channel-provision.d.ts +360 -0
- package/dist/channel-provision.js +881 -0
- package/dist/ci-scaffold.d.ts +31 -0
- package/dist/ci-scaffold.js +343 -0
- package/dist/compile-check.d.ts +90 -0
- package/dist/compile-check.js +285 -0
- package/dist/compliance-schedule.d.ts +35 -0
- package/dist/compliance-schedule.js +36 -0
- package/dist/context-pressure.d.ts +80 -0
- package/dist/context-pressure.js +166 -0
- package/dist/dataset-mine.d.ts +172 -0
- package/dist/dataset-mine.js +403 -0
- package/dist/datasets.d.ts +124 -0
- package/dist/datasets.js +260 -0
- package/dist/deploy-canary.d.ts +83 -0
- package/dist/deploy-canary.js +87 -0
- package/dist/doctor-checks.d.ts +33 -0
- package/dist/doctor-checks.js +92 -0
- package/dist/doctor-detect.d.ts +108 -0
- package/dist/doctor-detect.js +214 -0
- package/dist/doctor-fix.d.ts +81 -0
- package/dist/doctor-fix.js +164 -0
- package/dist/egress-triage.d.ts +121 -0
- package/dist/egress-triage.js +261 -0
- package/dist/eval-bridge.d.ts +114 -0
- package/dist/eval-bridge.js +158 -0
- package/dist/eval-coverage.d.ts +140 -0
- package/dist/eval-coverage.js +428 -0
- package/dist/eval-history.d.ts +48 -0
- package/dist/eval-history.js +157 -0
- package/dist/eval-matrix.d.ts +80 -0
- package/dist/eval-matrix.js +182 -0
- package/dist/eval-sentinel.d.ts +65 -0
- package/dist/eval-sentinel.js +132 -0
- package/dist/faq.d.ts +68 -0
- package/dist/faq.js +168 -0
- package/dist/feedback.d.ts +246 -0
- package/dist/feedback.js +612 -0
- package/dist/fewshot.d.ts +83 -0
- package/dist/fewshot.js +158 -0
- package/dist/fleet.d.ts +207 -0
- package/dist/fleet.js +488 -0
- package/dist/flywheel.d.ts +193 -0
- package/dist/flywheel.js +519 -0
- package/dist/graders-suggest.d.ts +186 -0
- package/dist/graders-suggest.js +658 -0
- package/dist/incident.d.ts +99 -0
- package/dist/incident.js +217 -0
- package/dist/index.d.ts +9 -1
- package/dist/index.js +11727 -740
- package/dist/init-interactive.d.ts +105 -0
- package/dist/init-interactive.js +208 -0
- package/dist/intents.d.ts +105 -0
- package/dist/intents.js +292 -0
- package/dist/judge-calibrate.d.ts +137 -0
- package/dist/judge-calibrate.js +247 -0
- package/dist/justification-calibrate.d.ts +150 -0
- package/dist/justification-calibrate.js +262 -0
- package/dist/justification-gate.d.ts +27 -6
- package/dist/justification-gate.js +30 -6
- package/dist/knowledge-sync.d.ts +179 -0
- package/dist/knowledge-sync.js +551 -0
- package/dist/lessons.d.ts +87 -0
- package/dist/lessons.js +207 -0
- package/dist/lint.d.ts +127 -0
- package/dist/lint.js +226 -0
- package/dist/loadtest.d.ts +114 -0
- package/dist/loadtest.js +196 -0
- package/dist/marketplace-cli.d.ts +110 -0
- package/dist/marketplace-cli.js +250 -0
- package/dist/mcp-doctor.d.ts +121 -0
- package/dist/mcp-doctor.js +249 -0
- package/dist/model-scan.d.ts +116 -0
- package/dist/model-scan.js +226 -0
- package/dist/onchain-tune.d.ts +164 -0
- package/dist/onchain-tune.js +346 -0
- package/dist/permissions-suggest.d.ts +126 -0
- package/dist/permissions-suggest.js +333 -0
- package/dist/pii-tune.d.ts +107 -0
- package/dist/pii-tune.js +122 -0
- package/dist/propose.d.ts +117 -0
- package/dist/propose.js +184 -0
- package/dist/refresh-goldens.d.ts +82 -0
- package/dist/refresh-goldens.js +221 -0
- package/dist/regression-pin.d.ts +160 -0
- package/dist/regression-pin.js +281 -0
- package/dist/retention.d.ts +193 -0
- package/dist/retention.js +607 -0
- package/dist/retire.d.ts +118 -0
- package/dist/retire.js +291 -0
- package/dist/right-size.d.ts +100 -0
- package/dist/right-size.js +123 -0
- package/dist/scaffold-evals.d.ts +138 -0
- package/dist/scaffold-evals.js +410 -0
- package/dist/scope-audit-drift.d.ts +139 -0
- package/dist/scope-audit-drift.js +260 -0
- package/dist/security-corpus.d.ts +237 -0
- package/dist/security-corpus.js +516 -0
- package/dist/security-digest.d.ts +173 -0
- package/dist/security-digest.js +650 -0
- package/dist/sessions-index.d.ts +27 -0
- package/dist/sessions-index.js +51 -0
- package/dist/slo-doctor.d.ts +67 -0
- package/dist/slo-doctor.js +119 -0
- package/dist/slo-sink.d.ts +96 -0
- package/dist/slo-sink.js +107 -0
- package/dist/spec-changelog.d.ts +102 -0
- package/dist/spec-changelog.js +237 -0
- package/dist/state-backup.d.ts +223 -0
- package/dist/state-backup.js +648 -0
- package/dist/tools-cli.d.ts +170 -0
- package/dist/tools-cli.js +298 -0
- package/dist/triage.d.ts +202 -0
- package/dist/triage.js +403 -0
- package/dist/upgrade.d.ts +57 -0
- package/dist/upgrade.js +113 -0
- package/dist/version.d.ts +6 -0
- package/dist/version.js +27 -0
- package/dist/voice-eval.d.ts +138 -0
- package/dist/voice-eval.js +309 -0
- package/dist/watch.d.ts +58 -0
- package/dist/watch.js +97 -0
- package/package.json +89 -64
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Item 31 — construct the `AlertSink` the alert watchdog uses for durable +
|
|
3
|
+
* off-box delivery of a threshold breach. Two channels, both best-effort:
|
|
4
|
+
* - `appendAudit` → a tamper-evident `alert_raised` record on the
|
|
5
|
+
* hash-chained audit log (so the alert history itself is
|
|
6
|
+
* auditable);
|
|
7
|
+
* - `fireAlertHook` → the settings.json `alert` hook (HOOK_EVENTS gained
|
|
8
|
+
* `"alert"` additively) AND/OR an `alerts.webhook` POST.
|
|
9
|
+
*
|
|
10
|
+
* Side-effect-free + injected (audit log, hook runner, hook list, fetch) so
|
|
11
|
+
* this entry file (which runs an argv switch on import) stays testable and no
|
|
12
|
+
* network/audit I/O happens at import time. `crewhaus run` wires the real
|
|
13
|
+
* `openAuditLog`, `loadHooks`+`runHooks`, and `globalThis.fetch`.
|
|
14
|
+
*/
|
|
15
|
+
import type { AlertSink } from "@crewhaus/runtime-core";
|
|
16
|
+
/** Minimal audit seam — structurally satisfied by `@crewhaus/audit-log`. */
|
|
17
|
+
export type AlertAuditSink = {
|
|
18
|
+
append(input: {
|
|
19
|
+
readonly kind: "alert_raised";
|
|
20
|
+
readonly payload: unknown;
|
|
21
|
+
}): Promise<unknown>;
|
|
22
|
+
};
|
|
23
|
+
/** Minimal hook seam — structurally satisfied by `hooks-engine`'s `runHooks`. */
|
|
24
|
+
export type AlertHookRunner = (event: "alert", payload: unknown, matcherKey: string) => Promise<void>;
|
|
25
|
+
export type BuildAlertSinkOptions = {
|
|
26
|
+
/** Durable audit log (undefined ⇒ no audit channel). */
|
|
27
|
+
readonly audit?: AlertAuditSink;
|
|
28
|
+
/** Runs settings.json `alert` hooks (undefined ⇒ no hook channel). */
|
|
29
|
+
readonly runAlertHooks?: AlertHookRunner;
|
|
30
|
+
/** Webhook URL to POST the breach JSON to (undefined ⇒ no webhook channel). */
|
|
31
|
+
readonly webhookUrl?: string;
|
|
32
|
+
/** Injected fetch (defaults to global fetch) for the webhook POST. */
|
|
33
|
+
readonly fetchImpl?: typeof fetch;
|
|
34
|
+
/** Warn sink for best-effort delivery failures (defaults to stderr). */
|
|
35
|
+
readonly warn?: (line: string) => void;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Build the `AlertSink`, or `undefined` when NO channel is configured (so the
|
|
39
|
+
* caller spreads nothing into `runChatLoop` and the watchdog keeps only its
|
|
40
|
+
* trace-event + snapshot behaviour). Every channel is best-effort: a failure
|
|
41
|
+
* is warned, never thrown — an alert-delivery hiccup must not fail a run.
|
|
42
|
+
*/
|
|
43
|
+
export declare function buildAlertSink(opts: BuildAlertSinkOptions): AlertSink | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* Read the optional `alerts` block from a parsed settings.json root. Only the
|
|
46
|
+
* `webhook` string is consumed today; unknown keys are ignored (additive).
|
|
47
|
+
*/
|
|
48
|
+
export declare function alertWebhookFromSettings(settingsRoot: unknown): string | undefined;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build the `AlertSink`, or `undefined` when NO channel is configured (so the
|
|
3
|
+
* caller spreads nothing into `runChatLoop` and the watchdog keeps only its
|
|
4
|
+
* trace-event + snapshot behaviour). Every channel is best-effort: a failure
|
|
5
|
+
* is warned, never thrown — an alert-delivery hiccup must not fail a run.
|
|
6
|
+
*/
|
|
7
|
+
export function buildAlertSink(opts) {
|
|
8
|
+
const hasAudit = opts.audit !== undefined;
|
|
9
|
+
const hasHooks = opts.runAlertHooks !== undefined;
|
|
10
|
+
const hasWebhook = typeof opts.webhookUrl === "string" && opts.webhookUrl !== "";
|
|
11
|
+
if (!hasAudit && !hasHooks && !hasWebhook)
|
|
12
|
+
return undefined;
|
|
13
|
+
const warn = opts.warn ?? ((line) => process.stderr.write(`${line}\n`));
|
|
14
|
+
const sink = {};
|
|
15
|
+
if (hasAudit) {
|
|
16
|
+
sink.appendAudit = async (breach) => {
|
|
17
|
+
await opts.audit?.append({ kind: "alert_raised", payload: breach });
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
if (hasHooks || hasWebhook) {
|
|
21
|
+
sink.fireAlertHook = async (breach) => {
|
|
22
|
+
// Match the hook by the breached metric name so a settings.json hook can
|
|
23
|
+
// target `matcher: "cost_burn_usd_per_min"`, etc.
|
|
24
|
+
if (hasHooks) {
|
|
25
|
+
try {
|
|
26
|
+
await opts.runAlertHooks?.("alert", breach, "metric");
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
warn(`[alert] hook failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (hasWebhook && opts.webhookUrl !== undefined) {
|
|
33
|
+
// F5 — warn (never block) on a non-https scheme, mirroring
|
|
34
|
+
// security-digest.ts's notifySecurityDigest: an alert breach payload
|
|
35
|
+
// carries session/metric/threshold detail, and posting it cleartext
|
|
36
|
+
// leaks it to every on-path observer. Advisory only — the webhook
|
|
37
|
+
// still POSTs; a local tunnel/dev receiver is a legitimate use case.
|
|
38
|
+
if (isNonHttpsUrl(opts.webhookUrl)) {
|
|
39
|
+
warn(`[alert] webhook ${opts.webhookUrl} uses plain http — the breach payload will transit unencrypted; prefer an https webhook`);
|
|
40
|
+
}
|
|
41
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
42
|
+
try {
|
|
43
|
+
const res = await doFetch(opts.webhookUrl, {
|
|
44
|
+
method: "POST",
|
|
45
|
+
headers: { "content-type": "application/json" },
|
|
46
|
+
body: JSON.stringify(breach),
|
|
47
|
+
});
|
|
48
|
+
if (!res.ok)
|
|
49
|
+
warn(`[alert] webhook POST returned ${res.status}`);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
warn(`[alert] webhook POST failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return sink;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* F5 — true when `url` parses and its scheme is anything other than
|
|
61
|
+
* `https:` (notably plain `http:`). An unparseable URL returns `false` here
|
|
62
|
+
* — the webhook POST itself will surface that failure via its own
|
|
63
|
+
* try/catch, so this check only ever adds the scheme warning, never a new
|
|
64
|
+
* failure mode.
|
|
65
|
+
*/
|
|
66
|
+
function isNonHttpsUrl(url) {
|
|
67
|
+
try {
|
|
68
|
+
return new URL(url).protocol !== "https:";
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Read the optional `alerts` block from a parsed settings.json root. Only the
|
|
76
|
+
* `webhook` string is consumed today; unknown keys are ignored (additive).
|
|
77
|
+
*/
|
|
78
|
+
export function alertWebhookFromSettings(settingsRoot) {
|
|
79
|
+
if (typeof settingsRoot !== "object" || settingsRoot === null)
|
|
80
|
+
return undefined;
|
|
81
|
+
const alerts = settingsRoot.alerts;
|
|
82
|
+
if (typeof alerts !== "object" || alerts === null)
|
|
83
|
+
return undefined;
|
|
84
|
+
const webhook = alerts.webhook;
|
|
85
|
+
return typeof webhook === "string" && webhook !== "" ? webhook : undefined;
|
|
86
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/** Thrown for malformed config / approvals files. The CLI routes the message
|
|
2
|
+
* through `die()`; tests assert on `.message`. */
|
|
3
|
+
export declare class ApprovalGateError extends Error {
|
|
4
|
+
readonly name = "ApprovalGateError";
|
|
5
|
+
}
|
|
6
|
+
export declare const ENVIRONMENTS_CONFIG_RELPATH = ".crewhaus/environments.json";
|
|
7
|
+
export declare const APPROVALS_RELDIR = ".crewhaus/approvals";
|
|
8
|
+
export type EnvironmentPolicy = {
|
|
9
|
+
readonly requireApproval: boolean;
|
|
10
|
+
/** Distinct approvals required to flip this env. Defaults to 1 when
|
|
11
|
+
* requireApproval is true and unset. */
|
|
12
|
+
readonly minApprovals: number;
|
|
13
|
+
};
|
|
14
|
+
export type EnvironmentsConfig = {
|
|
15
|
+
readonly environments: Readonly<Record<string, EnvironmentPolicy>>;
|
|
16
|
+
/** true when a real config file was found (vs the all-unprotected default). */
|
|
17
|
+
readonly fromFile: boolean;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Load `.crewhaus/environments.json` from a harness root. A missing file
|
|
21
|
+
* yields the all-unprotected default (`fromFile: false`). A present-but-
|
|
22
|
+
* malformed file throws — a protected-env policy that silently degraded to
|
|
23
|
+
* "unprotected" would be the worst possible failure mode.
|
|
24
|
+
*/
|
|
25
|
+
export declare function loadEnvironmentsConfig(rootDir: string): EnvironmentsConfig;
|
|
26
|
+
/** The policy for `env`, or an unprotected default. */
|
|
27
|
+
export declare function policyForEnv(config: EnvironmentsConfig, env: string): EnvironmentPolicy;
|
|
28
|
+
export type ApprovalRecord = {
|
|
29
|
+
readonly approver: string;
|
|
30
|
+
readonly ts: string;
|
|
31
|
+
/** Candidate version this approval is FOR; when set it must match the
|
|
32
|
+
* version being promoted to count. */
|
|
33
|
+
readonly version?: string;
|
|
34
|
+
readonly comment?: string;
|
|
35
|
+
};
|
|
36
|
+
/** Approvals file name for a (spec, env) pair. `/` is not possible in a spec
|
|
37
|
+
* registry name, so `__` is a safe joiner. */
|
|
38
|
+
export declare function approvalsFileName(specName: string, env: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* Read recorded approvals for a (spec, env) from
|
|
41
|
+
* `.crewhaus/approvals/<spec>__<env>.json`. Missing file → []. A malformed
|
|
42
|
+
* file throws (a dropped approval could silently under-count the quorum).
|
|
43
|
+
*/
|
|
44
|
+
export declare function readApprovals(rootDir: string, specName: string, env: string): ApprovalRecord[];
|
|
45
|
+
export type PrCheckState = {
|
|
46
|
+
/** "success" | "pending" | "failure" | "none" (no PR found for the change). */
|
|
47
|
+
readonly conclusion: "success" | "pending" | "failure" | "none";
|
|
48
|
+
readonly prNumber?: number;
|
|
49
|
+
readonly url?: string;
|
|
50
|
+
};
|
|
51
|
+
/** Reader seam: resolve the PR-check state for a (spec, env, version) change.
|
|
52
|
+
* Production drives `gh`; tests inject a fixed state. */
|
|
53
|
+
export type PrCheckReader = (args: {
|
|
54
|
+
readonly specName: string;
|
|
55
|
+
readonly env: string;
|
|
56
|
+
readonly version: string;
|
|
57
|
+
}) => Promise<PrCheckState>;
|
|
58
|
+
/** One entry in a GitHub PR's `statusCheckRollup` — a StatusContext carries a
|
|
59
|
+
* `state`, a CheckRun carries a `conclusion`; either (or neither) may be set. */
|
|
60
|
+
export type RollupCheck = {
|
|
61
|
+
readonly conclusion?: string;
|
|
62
|
+
readonly state?: string;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* F3(a) — FAIL-CLOSED rollup verdict. A PR is a `success` witness ONLY when it
|
|
66
|
+
* has at least one check AND every check terminated in an explicit SUCCESS.
|
|
67
|
+
* Any failure/cancel/timeout, any still-running check, and any
|
|
68
|
+
* SKIPPED/NEUTRAL/unknown conclusion yields NOT a witness — an ambiguous or
|
|
69
|
+
* non-passing state must never sign off a protected promotion.
|
|
70
|
+
*/
|
|
71
|
+
export declare function rollupConclusion(rollup: ReadonlyArray<RollupCheck>): "success" | "pending" | "failure" | "none";
|
|
72
|
+
/**
|
|
73
|
+
* F3(b) — does this PR reference `version` as a bounded token in its head ref,
|
|
74
|
+
* title, or body? `propose` names the branch `propose/<slug>-<version>-<hash>-
|
|
75
|
+
* <stamp>`, so the version is a bounded segment of the head ref; a bare
|
|
76
|
+
* substring match would let `v1` witness `v10`, so require non-alphanumeric
|
|
77
|
+
* (or string-edge) bounds. Binds the PR witness to the EXACT promoted version.
|
|
78
|
+
*/
|
|
79
|
+
export declare function prReferencesVersion(pr: {
|
|
80
|
+
readonly headRefName?: string;
|
|
81
|
+
readonly title?: string;
|
|
82
|
+
readonly body?: string;
|
|
83
|
+
}, version: string): boolean;
|
|
84
|
+
export type ApprovalDecisionInput = {
|
|
85
|
+
readonly specName: string;
|
|
86
|
+
readonly toEnv: string;
|
|
87
|
+
/** The candidate version the promotion would pin to. */
|
|
88
|
+
readonly toVersion: string;
|
|
89
|
+
readonly policy: EnvironmentPolicy;
|
|
90
|
+
readonly approvals: ReadonlyArray<ApprovalRecord>;
|
|
91
|
+
/** Present only when a PR-check witness was consulted. */
|
|
92
|
+
readonly prCheck?: PrCheckState;
|
|
93
|
+
};
|
|
94
|
+
export type ApprovalDecision = {
|
|
95
|
+
readonly satisfied: boolean;
|
|
96
|
+
/** Distinct approvers whose approval is valid for this candidate version. */
|
|
97
|
+
readonly countedApprovers: ReadonlyArray<string>;
|
|
98
|
+
/** True when a green PR check contributed a witness. */
|
|
99
|
+
readonly prWitness: boolean;
|
|
100
|
+
readonly required: number;
|
|
101
|
+
readonly reason: string;
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Decide whether the gate is satisfied. Counts DISTINCT approvers whose
|
|
105
|
+
* approval either carries no version pin or matches `toVersion`, plus one
|
|
106
|
+
* collective witness for a green PR check. Satisfied when
|
|
107
|
+
* `witnesses >= required`.
|
|
108
|
+
*/
|
|
109
|
+
export declare function decideApproval(input: ApprovalDecisionInput): ApprovalDecision;
|
|
110
|
+
/** The `governance_approval` audit payload for a gate evaluation. Recorded
|
|
111
|
+
* whether or not the gate was satisfied. */
|
|
112
|
+
export type GovernanceApprovalPayload = {
|
|
113
|
+
readonly name: string;
|
|
114
|
+
readonly toEnv: string;
|
|
115
|
+
readonly toVersion: string;
|
|
116
|
+
readonly required: number;
|
|
117
|
+
readonly satisfied: boolean;
|
|
118
|
+
readonly countedApprovers: ReadonlyArray<string>;
|
|
119
|
+
readonly prWitness: boolean;
|
|
120
|
+
readonly prNumber?: number;
|
|
121
|
+
readonly actor?: string;
|
|
122
|
+
readonly ts: number;
|
|
123
|
+
};
|
|
124
|
+
export declare function buildGovernancePayload(input: ApprovalDecisionInput, decision: ApprovalDecision, opts: {
|
|
125
|
+
readonly actor?: string;
|
|
126
|
+
readonly now: () => number;
|
|
127
|
+
}): GovernanceApprovalPayload;
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Item 59 (half 1) — approval-gated promotion. `crewhaus deploy promote
|
|
3
|
+
* --require-approval` must not flip a PROTECTED environment's pin until an
|
|
4
|
+
* approval quorum is satisfied: a recorded set of approval tokens AND/OR a
|
|
5
|
+
* green PR check. The gate is a side-effect-free decision layer that runs
|
|
6
|
+
* BEFORE the deployment-controller's `promote` — the controller stays a dumb
|
|
7
|
+
* pin-flipper; policy lives here (and is testable without a registry).
|
|
8
|
+
*
|
|
9
|
+
* Factored out of the entry file `index.ts` (which runs a top-level argv
|
|
10
|
+
* switch and so cannot be imported by a test without executing the CLI).
|
|
11
|
+
* Mirrors `retention.ts` / `audit-verify.ts`: pure config + decision, plus a
|
|
12
|
+
* thin recorded-approvals reader over `.crewhaus/approvals`.
|
|
13
|
+
*
|
|
14
|
+
* WHAT IS PROTECTED — `.crewhaus/environments.json` declares which
|
|
15
|
+
* environments require approval and how many:
|
|
16
|
+
*
|
|
17
|
+
* { "environments": {
|
|
18
|
+
* "prod": { "requireApproval": true, "minApprovals": 2 },
|
|
19
|
+
* "staging": { "requireApproval": false }
|
|
20
|
+
* } }
|
|
21
|
+
*
|
|
22
|
+
* A missing file / missing entry means the env is UNPROTECTED (the pre-item-59
|
|
23
|
+
* behavior — `promote` runs unconditionally). `--require-approval` is opt-in
|
|
24
|
+
* per invocation; an env can also be marked protected in the config so a
|
|
25
|
+
* plain `promote` to it is refused with a pointer to `--require-approval`.
|
|
26
|
+
*
|
|
27
|
+
* WHAT SATISFIES THE GATE — two independent, OR-combined witnesses:
|
|
28
|
+
* 1. Recorded approvals: `.crewhaus/approvals/<spec>__<env>.json` holds an
|
|
29
|
+
* array of `{ approver, ts, version? }`. Distinct approvers count toward
|
|
30
|
+
* `minApprovals`; an approval pinned to a DIFFERENT candidate version
|
|
31
|
+
* than the one being promoted does not count (you approved v2, not v3).
|
|
32
|
+
* 2. A green PR check: an injected `PrCheckReader` resolves the merge/CI
|
|
33
|
+
* state of the proposal PR for this change. A `success` conclusion
|
|
34
|
+
* counts as one collective approval witness (a reviewed+green PR is the
|
|
35
|
+
* team's sign-off) — enough on its own only when `minApprovals <= 1`,
|
|
36
|
+
* otherwise it ADDS to the recorded-approval count.
|
|
37
|
+
*
|
|
38
|
+
* The gate NEVER approves itself: with no witnesses it refuses, and the
|
|
39
|
+
* refusal is as audit-worthy as the approval — both produce a
|
|
40
|
+
* `governance_approval` record so a scheduled promotion that was blocked is
|
|
41
|
+
* evidenced too.
|
|
42
|
+
*/
|
|
43
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
44
|
+
import { join } from "node:path";
|
|
45
|
+
/** Thrown for malformed config / approvals files. The CLI routes the message
|
|
46
|
+
* through `die()`; tests assert on `.message`. */
|
|
47
|
+
export class ApprovalGateError extends Error {
|
|
48
|
+
name = "ApprovalGateError";
|
|
49
|
+
}
|
|
50
|
+
export const ENVIRONMENTS_CONFIG_RELPATH = ".crewhaus/environments.json";
|
|
51
|
+
export const APPROVALS_RELDIR = ".crewhaus/approvals";
|
|
52
|
+
/**
|
|
53
|
+
* Load `.crewhaus/environments.json` from a harness root. A missing file
|
|
54
|
+
* yields the all-unprotected default (`fromFile: false`). A present-but-
|
|
55
|
+
* malformed file throws — a protected-env policy that silently degraded to
|
|
56
|
+
* "unprotected" would be the worst possible failure mode.
|
|
57
|
+
*/
|
|
58
|
+
export function loadEnvironmentsConfig(rootDir) {
|
|
59
|
+
const path = join(rootDir, ENVIRONMENTS_CONFIG_RELPATH);
|
|
60
|
+
if (!existsSync(path))
|
|
61
|
+
return { environments: {}, fromFile: false };
|
|
62
|
+
let parsed;
|
|
63
|
+
try {
|
|
64
|
+
parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
throw new ApprovalGateError(`${ENVIRONMENTS_CONFIG_RELPATH} is not valid JSON: ${err.message}`);
|
|
68
|
+
}
|
|
69
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
70
|
+
throw new ApprovalGateError(`${ENVIRONMENTS_CONFIG_RELPATH} must be a JSON object`);
|
|
71
|
+
}
|
|
72
|
+
const envsRaw = parsed["environments"];
|
|
73
|
+
if (typeof envsRaw !== "object" || envsRaw === null) {
|
|
74
|
+
throw new ApprovalGateError(`${ENVIRONMENTS_CONFIG_RELPATH} must carry an "environments" object`);
|
|
75
|
+
}
|
|
76
|
+
const environments = {};
|
|
77
|
+
for (const [env, v] of Object.entries(envsRaw)) {
|
|
78
|
+
if (typeof v !== "object" || v === null) {
|
|
79
|
+
throw new ApprovalGateError(`environments.${env} must be an object`);
|
|
80
|
+
}
|
|
81
|
+
const rec = v;
|
|
82
|
+
const requireApproval = rec["requireApproval"] === true;
|
|
83
|
+
let minApprovals = 1;
|
|
84
|
+
if (rec["minApprovals"] !== undefined) {
|
|
85
|
+
const n = rec["minApprovals"];
|
|
86
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 1) {
|
|
87
|
+
throw new ApprovalGateError(`environments.${env}.minApprovals must be a positive integer`);
|
|
88
|
+
}
|
|
89
|
+
minApprovals = n;
|
|
90
|
+
}
|
|
91
|
+
environments[env] = { requireApproval, minApprovals };
|
|
92
|
+
}
|
|
93
|
+
return { environments, fromFile: true };
|
|
94
|
+
}
|
|
95
|
+
/** The policy for `env`, or an unprotected default. */
|
|
96
|
+
export function policyForEnv(config, env) {
|
|
97
|
+
return config.environments[env] ?? { requireApproval: false, minApprovals: 1 };
|
|
98
|
+
}
|
|
99
|
+
/** Approvals file name for a (spec, env) pair. `/` is not possible in a spec
|
|
100
|
+
* registry name, so `__` is a safe joiner. */
|
|
101
|
+
export function approvalsFileName(specName, env) {
|
|
102
|
+
return `${specName}__${env}.json`;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Read recorded approvals for a (spec, env) from
|
|
106
|
+
* `.crewhaus/approvals/<spec>__<env>.json`. Missing file → []. A malformed
|
|
107
|
+
* file throws (a dropped approval could silently under-count the quorum).
|
|
108
|
+
*/
|
|
109
|
+
export function readApprovals(rootDir, specName, env) {
|
|
110
|
+
const path = join(rootDir, APPROVALS_RELDIR, approvalsFileName(specName, env));
|
|
111
|
+
if (!existsSync(path))
|
|
112
|
+
return [];
|
|
113
|
+
let parsed;
|
|
114
|
+
try {
|
|
115
|
+
parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
throw new ApprovalGateError(`approvals file ${path} is not valid JSON: ${err.message}`);
|
|
119
|
+
}
|
|
120
|
+
if (!Array.isArray(parsed)) {
|
|
121
|
+
throw new ApprovalGateError(`approvals file ${path} must be a JSON array of { approver, ts }`);
|
|
122
|
+
}
|
|
123
|
+
const out = [];
|
|
124
|
+
for (const item of parsed) {
|
|
125
|
+
if (typeof item !== "object" || item === null)
|
|
126
|
+
continue;
|
|
127
|
+
const rec = item;
|
|
128
|
+
if (typeof rec["approver"] !== "string" || typeof rec["ts"] !== "string")
|
|
129
|
+
continue;
|
|
130
|
+
out.push({
|
|
131
|
+
approver: rec["approver"],
|
|
132
|
+
ts: rec["ts"],
|
|
133
|
+
...(typeof rec["version"] === "string" ? { version: rec["version"] } : {}),
|
|
134
|
+
...(typeof rec["comment"] === "string" ? { comment: rec["comment"] } : {}),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
/** Terminal check conclusions/states that mean FAILURE (fail-closed). */
|
|
140
|
+
const ROLLUP_FAILED = new Set([
|
|
141
|
+
"FAILURE",
|
|
142
|
+
"CANCELLED",
|
|
143
|
+
"TIMED_OUT",
|
|
144
|
+
"ACTION_REQUIRED",
|
|
145
|
+
"STARTUP_FAILURE",
|
|
146
|
+
"STALE",
|
|
147
|
+
"ERROR",
|
|
148
|
+
]);
|
|
149
|
+
/** States that mean a check is still RUNNING (not yet a witness). */
|
|
150
|
+
const ROLLUP_RUNNING = new Set([
|
|
151
|
+
"PENDING",
|
|
152
|
+
"IN_PROGRESS",
|
|
153
|
+
"QUEUED",
|
|
154
|
+
"WAITING",
|
|
155
|
+
"REQUESTED",
|
|
156
|
+
]);
|
|
157
|
+
/**
|
|
158
|
+
* F3(a) — FAIL-CLOSED rollup verdict. A PR is a `success` witness ONLY when it
|
|
159
|
+
* has at least one check AND every check terminated in an explicit SUCCESS.
|
|
160
|
+
* Any failure/cancel/timeout, any still-running check, and any
|
|
161
|
+
* SKIPPED/NEUTRAL/unknown conclusion yields NOT a witness — an ambiguous or
|
|
162
|
+
* non-passing state must never sign off a protected promotion.
|
|
163
|
+
*/
|
|
164
|
+
export function rollupConclusion(rollup) {
|
|
165
|
+
if (rollup.length === 0)
|
|
166
|
+
return "none";
|
|
167
|
+
let anyFail = false;
|
|
168
|
+
let anyPending = false;
|
|
169
|
+
let allSuccess = true;
|
|
170
|
+
for (const c of rollup) {
|
|
171
|
+
const state = (c.state ?? "").toUpperCase();
|
|
172
|
+
const conclusion = (c.conclusion ?? "").toUpperCase();
|
|
173
|
+
const terminal = conclusion !== "" ? conclusion : state;
|
|
174
|
+
if (ROLLUP_RUNNING.has(state) || ROLLUP_RUNNING.has(conclusion) || terminal === "") {
|
|
175
|
+
anyPending = true;
|
|
176
|
+
}
|
|
177
|
+
if (ROLLUP_FAILED.has(state) || ROLLUP_FAILED.has(conclusion))
|
|
178
|
+
anyFail = true;
|
|
179
|
+
// Only an explicit SUCCESS counts toward "all passed"; SKIPPED/NEUTRAL/
|
|
180
|
+
// anything else leaves allSuccess false (fail-closed).
|
|
181
|
+
if (terminal !== "SUCCESS")
|
|
182
|
+
allSuccess = false;
|
|
183
|
+
}
|
|
184
|
+
if (anyFail)
|
|
185
|
+
return "failure";
|
|
186
|
+
if (anyPending)
|
|
187
|
+
return "pending";
|
|
188
|
+
return allSuccess ? "success" : "none";
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* F3(b) — does this PR reference `version` as a bounded token in its head ref,
|
|
192
|
+
* title, or body? `propose` names the branch `propose/<slug>-<version>-<hash>-
|
|
193
|
+
* <stamp>`, so the version is a bounded segment of the head ref; a bare
|
|
194
|
+
* substring match would let `v1` witness `v10`, so require non-alphanumeric
|
|
195
|
+
* (or string-edge) bounds. Binds the PR witness to the EXACT promoted version.
|
|
196
|
+
*/
|
|
197
|
+
export function prReferencesVersion(pr, version) {
|
|
198
|
+
const esc = version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
199
|
+
const bounded = new RegExp(`(^|[^A-Za-z0-9.])${esc}([^A-Za-z0-9.]|$)`);
|
|
200
|
+
return [pr.headRefName, pr.title, pr.body].some((s) => typeof s === "string" && bounded.test(s));
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Decide whether the gate is satisfied. Counts DISTINCT approvers whose
|
|
204
|
+
* approval either carries no version pin or matches `toVersion`, plus one
|
|
205
|
+
* collective witness for a green PR check. Satisfied when
|
|
206
|
+
* `witnesses >= required`.
|
|
207
|
+
*/
|
|
208
|
+
export function decideApproval(input) {
|
|
209
|
+
const required = input.policy.minApprovals;
|
|
210
|
+
const counted = new Set();
|
|
211
|
+
for (const a of input.approvals) {
|
|
212
|
+
if (a.version !== undefined && a.version !== input.toVersion)
|
|
213
|
+
continue;
|
|
214
|
+
counted.add(a.approver);
|
|
215
|
+
}
|
|
216
|
+
const prWitness = input.prCheck?.conclusion === "success";
|
|
217
|
+
const witnesses = counted.size + (prWitness ? 1 : 0);
|
|
218
|
+
const satisfied = witnesses >= required;
|
|
219
|
+
let reason;
|
|
220
|
+
if (satisfied) {
|
|
221
|
+
const parts = [];
|
|
222
|
+
if (counted.size > 0)
|
|
223
|
+
parts.push(`${counted.size} recorded approval(s): ${[...counted].sort().join(", ")}`);
|
|
224
|
+
if (prWitness)
|
|
225
|
+
parts.push(`green PR check (#${input.prCheck?.prNumber ?? "?"})`);
|
|
226
|
+
reason = `quorum met (${witnesses}/${required}) — ${parts.join("; ")}`;
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
const missing = required - witnesses;
|
|
230
|
+
const detail = [];
|
|
231
|
+
detail.push(`have ${witnesses} witness(es), need ${required}`);
|
|
232
|
+
if (counted.size > 0)
|
|
233
|
+
detail.push(`approvers: ${[...counted].sort().join(", ")}`);
|
|
234
|
+
if (input.prCheck !== undefined && !prWitness) {
|
|
235
|
+
detail.push(`PR check: ${input.prCheck.conclusion}`);
|
|
236
|
+
}
|
|
237
|
+
reason = `quorum NOT met — ${detail.join("; ")} (${missing} more needed; record approvals in ${APPROVALS_RELDIR}/${approvalsFileName(input.specName, input.toEnv)} or land a green proposal PR)`;
|
|
238
|
+
}
|
|
239
|
+
return { satisfied, countedApprovers: [...counted].sort(), prWitness, required, reason };
|
|
240
|
+
}
|
|
241
|
+
export function buildGovernancePayload(input, decision, opts) {
|
|
242
|
+
return {
|
|
243
|
+
name: input.specName,
|
|
244
|
+
toEnv: input.toEnv,
|
|
245
|
+
toVersion: input.toVersion,
|
|
246
|
+
required: decision.required,
|
|
247
|
+
satisfied: decision.satisfied,
|
|
248
|
+
countedApprovers: decision.countedApprovers,
|
|
249
|
+
prWitness: decision.prWitness,
|
|
250
|
+
...(input.prCheck?.prNumber !== undefined ? { prNumber: input.prCheck.prNumber } : {}),
|
|
251
|
+
...(opts.actor !== undefined ? { actor: opts.actor } : {}),
|
|
252
|
+
ts: opts.now(),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { VerifyResult } from "@crewhaus/audit-log";
|
|
2
|
+
/**
|
|
3
|
+
* Item 34 — `crewhaus audit verify` plumbing, factored out of the entry file
|
|
4
|
+
* `index.ts` (which runs a top-level argv switch and so cannot be imported by
|
|
5
|
+
* a test without executing the CLI). Side-effect-free and directly
|
|
6
|
+
* unit-testable, mirroring `scope-audit.ts` / `justification-gate.ts` /
|
|
7
|
+
* `doctor-checks.ts`.
|
|
8
|
+
*
|
|
9
|
+
* Two halves:
|
|
10
|
+
* 1. `resolveAnchorFlag` — parse the `--anchor <scheme>:<path>` flag. Only
|
|
11
|
+
* `file:` ships today (`@crewhaus/audit-log`'s `FileAnchorStore`); an
|
|
12
|
+
* unknown scheme throws so the entry file can `die()` with the allowed
|
|
13
|
+
* list instead of silently skipping the cross-check the user asked for.
|
|
14
|
+
* 2. `summarizeVerifyResult` — turn the library's `VerifyResult` into the
|
|
15
|
+
* per-check ✓/~/✗ summary lines + exit code. The exit-code contract:
|
|
16
|
+
* 0 only when the chain is intact AND (when an anchor store was
|
|
17
|
+
* explicitly requested) the external anchor was actually cross-checked —
|
|
18
|
+
* a requested-but-unconsulted anchor exits 1, because a scheduled
|
|
19
|
+
* tamper check that silently skipped its strongest witness is worse
|
|
20
|
+
* than one that fails loudly.
|
|
21
|
+
*/
|
|
22
|
+
export type AnchorFlagChoice = {
|
|
23
|
+
readonly scheme: "file";
|
|
24
|
+
readonly path: string;
|
|
25
|
+
};
|
|
26
|
+
/** Thrown by `resolveAnchorFlag` on a malformed `--anchor` value. The CLI
|
|
27
|
+
* entry file catches it and routes the message through `die()`; tests
|
|
28
|
+
* assert on `.message` without the process exiting. */
|
|
29
|
+
export declare class InvalidAnchorFlagError extends Error {
|
|
30
|
+
readonly value: string;
|
|
31
|
+
readonly name = "InvalidAnchorFlagError";
|
|
32
|
+
constructor(value: string);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Parse the raw `--anchor` flag value (or undefined when absent). `file:<path>`
|
|
36
|
+
* selects the file-backed AnchorStore rooted at `<path>`; anything else —
|
|
37
|
+
* missing scheme, empty path, unknown scheme (S3 et al. are out of scope) —
|
|
38
|
+
* throws {@link InvalidAnchorFlagError}.
|
|
39
|
+
*/
|
|
40
|
+
export declare function resolveAnchorFlag(flagValue: string | undefined): AnchorFlagChoice | undefined;
|
|
41
|
+
export type VerifySummary = {
|
|
42
|
+
readonly lines: ReadonlyArray<string>;
|
|
43
|
+
readonly exitCode: 0 | 1;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Render a `VerifyResult` as per-check summary lines + the process exit code.
|
|
47
|
+
* `anchorRequested` is whether the user passed `--anchor`: when true, an
|
|
48
|
+
* `ok` result whose external anchor was NOT actually consulted (store empty,
|
|
49
|
+
* unreadable, or lagging into nonexistence) is a FAILURE — the check the
|
|
50
|
+
* user scheduled did not run, so exiting 0 would fabricate assurance.
|
|
51
|
+
*/
|
|
52
|
+
export declare function summarizeVerifyResult(result: VerifyResult, opts: {
|
|
53
|
+
readonly anchorRequested: boolean;
|
|
54
|
+
}): VerifySummary;
|
|
55
|
+
/** Shape shared with `index.ts`'s DoctorCheck (label/pass/warn/reason). */
|
|
56
|
+
export type AuditIntegrityCheck = {
|
|
57
|
+
readonly label: string;
|
|
58
|
+
readonly pass: boolean;
|
|
59
|
+
readonly warn?: boolean;
|
|
60
|
+
readonly reason?: string;
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Map a `VerifyResult` onto the doctor's ✓/~/✗ check shape so `crewhaus
|
|
64
|
+
* doctor` can surface audit-log tampering when a `.crewhaus/audit` store
|
|
65
|
+
* exists in the cwd. An intact chain without the on-host anchor is a warn
|
|
66
|
+
* ("~"), never a failure — mirroring the library's limitation-not-tamper
|
|
67
|
+
* stance — and a broken chain fails doctor with the first finding inline.
|
|
68
|
+
*/
|
|
69
|
+
export declare function buildAuditIntegrityCheck(result: VerifyResult): AuditIntegrityCheck;
|