@codyswann/lisa 3.16.0 → 3.17.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/all/copy-overwrite/scripts/lisa-gates.mjs +1165 -0
- package/all/copy-overwrite/scripts/lisa-reconcile-policy.mjs +1188 -0
- package/all/copy-overwrite/scripts/lisa-run-gates.mjs +597 -0
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +24 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +72 -8
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/package.json +6 -2
- package/plugins/lisa/.claude-plugin/plugin.json +10 -1
- package/plugins/lisa/.codex-plugin/hooks.json +9 -0
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa/.codex-plugin/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa/hooks/secrets-preflight.sh +72 -0
- package/plugins/lisa/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa-agy/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa-agy/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +10 -1
- package/plugins/lisa-copilot/hooks/secrets-preflight.sh +72 -0
- package/plugins/lisa-copilot/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa-copilot/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa-copilot/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/hooks/hooks.json +3 -0
- package/plugins/lisa-cursor/hooks/secrets-preflight.sh +72 -0
- package/plugins/lisa-cursor/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/lisa-cursor/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/lisa-cursor/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/.claude-plugin/plugin.json +9 -0
- package/plugins/src/base/hooks/secrets-preflight.sh +72 -0
- package/plugins/src/base/skills/lisa-doctor/SKILL.md +108 -2
- package/plugins/src/base/skills/lisa-secrets-access/scripts/preflight-secrets.mjs +324 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/routing-floor.mjs +153 -0
- package/plugins/src/base/skills/lisa-secrets-access/scripts/surfaces.mjs +55 -7
- package/plugins/src/base/skills/lisa-secrets-access/scripts/validate-config.mjs +89 -17
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs +242 -0
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/tool-floor.mjs +94 -0
- package/plugins/src/base/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs +14 -32
- package/scripts/generate-lisa-owned-hash-ledger.mjs +10 -1
- package/scripts/lib/per-agent-hook-filter.mjs +18 -0
- package/typescript/copy-contents/.husky/pre-commit +130 -21
- package/typescript/copy-contents/.husky/pre-push +202 -22
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive the CLIs a project's own configuration already implies.
|
|
3
|
+
*
|
|
4
|
+
* The symmetric half of `routing-floor.mjs`. A credential and the binary that
|
|
5
|
+
* consumes it are the same question asked twice — can this agent do the work —
|
|
6
|
+
* and both were answered only by a hand-maintained list.
|
|
7
|
+
*
|
|
8
|
+
* `detect-tooling` already knows most of these pairings and states them well:
|
|
9
|
+
* "the work-item guardrails shell out to `gh` for every tracker read". But it
|
|
10
|
+
* knows them at *proposal* time, as suggestions a human runs a skill to see and
|
|
11
|
+
* then copies into config. Nothing derives them at runtime, so a project that
|
|
12
|
+
* never ran the skill — or ran it before it adopted Maestro — has a manifest
|
|
13
|
+
* that silently understates what its agents need.
|
|
14
|
+
*
|
|
15
|
+
* This promotes those pairings to a derivation. The floor is unioned into the
|
|
16
|
+
* required set, never written into config, so it cannot drift from the routing
|
|
17
|
+
* that produces it.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately narrow. An entry belongs here only when the configuration that
|
|
20
|
+
* implies it makes the tool unavoidable — not merely likely. A project can have
|
|
21
|
+
* `quality.testCoverage` thresholds and run them through any runner, so no CLI
|
|
22
|
+
* is derived from that; `secrets.provider: "bitwarden"` on the other hand
|
|
23
|
+
* cannot resolve one credential without `bws`.
|
|
24
|
+
* @module tool-floor
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Config predicates that make a CLI unavoidable, with the reason to report.
|
|
29
|
+
*
|
|
30
|
+
* The reason is not decoration. A missing tool has two valid remedies — install
|
|
31
|
+
* it, or change the configuration that demands it — and an operator cannot pick
|
|
32
|
+
* between them without knowing which line of config is responsible.
|
|
33
|
+
*/
|
|
34
|
+
const DERIVATIONS = [
|
|
35
|
+
{
|
|
36
|
+
tool: "gh",
|
|
37
|
+
applies: cfg => cfg.tracker === "github" || cfg.source === "github",
|
|
38
|
+
reason: cfg =>
|
|
39
|
+
`${cfg.tracker === "github" ? "tracker" : "source"} is "github", and ` +
|
|
40
|
+
`the work-item guardrails shell out to gh for every tracker read`,
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
tool: "bws",
|
|
44
|
+
applies: cfg => cfg.secrets?.provider === "bitwarden",
|
|
45
|
+
reason: () =>
|
|
46
|
+
`secrets.provider is "bitwarden", and the CLI is how every secret is ` +
|
|
47
|
+
`resolved`,
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
tool: "doppler",
|
|
51
|
+
applies: cfg => cfg.secrets?.provider === "doppler",
|
|
52
|
+
reason: () => `secrets.provider is "doppler"`,
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
tool: "maestro",
|
|
56
|
+
// Keyed on the coverage block rather than a `.maestro` directory: config is
|
|
57
|
+
// the declaration that this project gates on Maestro, where a stray
|
|
58
|
+
// directory could be a leftover. `detect-tooling` uses the directory
|
|
59
|
+
// because it is guessing; here the project has already said so.
|
|
60
|
+
applies: cfg => cfg.quality?.e2eCoverage?.maestro !== undefined,
|
|
61
|
+
reason: () =>
|
|
62
|
+
`quality.e2eCoverage.maestro is configured, and nothing installs the ` +
|
|
63
|
+
`maestro binary`,
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The CLIs a project's configuration makes mandatory.
|
|
69
|
+
* @param {object} [config] Parsed `.lisa.config.json` root.
|
|
70
|
+
* @returns {Array<{name: string, reason: string}>} Sorted derived tools.
|
|
71
|
+
*/
|
|
72
|
+
export function toolFloor(config = {}) {
|
|
73
|
+
return DERIVATIONS.filter(entry => safely(() => entry.applies(config)))
|
|
74
|
+
.map(entry => ({ name: entry.tool, reason: entry.reason(config) }))
|
|
75
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Evaluate a predicate, treating a malformed config as "does not apply".
|
|
80
|
+
*
|
|
81
|
+
* A config shaped unexpectedly is a problem for the schema validator to report
|
|
82
|
+
* in its own vocabulary. Throwing here would make a readiness check the place a
|
|
83
|
+
* structural defect first surfaces, with a message about tools that names
|
|
84
|
+
* nothing an operator can act on.
|
|
85
|
+
* @param {Function} predicate Test to run.
|
|
86
|
+
* @returns {boolean} The result, or false when it threw.
|
|
87
|
+
*/
|
|
88
|
+
function safely(predicate) {
|
|
89
|
+
try {
|
|
90
|
+
return Boolean(predicate());
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
package/plugins/lisa/.codex-plugin/skills/lisa-setup-remote-env/scripts/verify-remote-env.mjs
CHANGED
|
@@ -25,8 +25,8 @@ import { execFileSync } from "node:child_process";
|
|
|
25
25
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
26
26
|
import { join } from "node:path";
|
|
27
27
|
|
|
28
|
-
import { readRemoteEnvConfig } from "./setup-remote-env.mjs";
|
|
29
|
-
import {
|
|
28
|
+
import { probe, readRemoteEnvConfig } from "./setup-remote-env.mjs";
|
|
29
|
+
import { planToolchain } from "./toolchain.mjs";
|
|
30
30
|
|
|
31
31
|
/** Collected results, so every check runs before anything reports failure. */
|
|
32
32
|
const results = [];
|
|
@@ -84,39 +84,21 @@ function node(args) {
|
|
|
84
84
|
|
|
85
85
|
/**
|
|
86
86
|
* Assert each declared tool is present at its pinned or minimum version.
|
|
87
|
+
*
|
|
88
|
+
* Delegates every decision to `planToolchain` rather than probing here. This
|
|
89
|
+
* function used to run its own loop, and the copy silently forgot `minVersion`:
|
|
90
|
+
* a `require` entry passed as long as the binary answered `--version` at all,
|
|
91
|
+
* so a container verified clean against a node older than the manifest
|
|
92
|
+
* demanded. The plan-side check rejected exactly what this one accepted.
|
|
93
|
+
*
|
|
94
|
+
* That is the same failure the note validators had before one shared module
|
|
95
|
+
* replaced them, and it recurs for the same reason — two implementations of one
|
|
96
|
+
* question cannot be kept in step by intention. There is now one.
|
|
87
97
|
* @param {object} tools Toolchain manifest.
|
|
88
98
|
*/
|
|
89
99
|
function verifyToolchain(tools) {
|
|
90
|
-
for (const
|
|
91
|
-
|
|
92
|
-
const out = execFileSync(tool.name, ["--version"], {
|
|
93
|
-
encoding: "utf8",
|
|
94
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
95
|
-
});
|
|
96
|
-
check(true, `tool ${tool.name}`, extractVersion(out) ?? "present");
|
|
97
|
-
} catch {
|
|
98
|
-
check(false, `tool ${tool.name}`, "required but not present");
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
for (const tool of tools.install ?? []) {
|
|
102
|
-
try {
|
|
103
|
-
const out = execFileSync(tool.name, ["--version"], {
|
|
104
|
-
encoding: "utf8",
|
|
105
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
106
|
-
});
|
|
107
|
-
const found = extractVersion(out);
|
|
108
|
-
check(
|
|
109
|
-
found === tool.version,
|
|
110
|
-
`tool ${tool.name}`,
|
|
111
|
-
`${found} (pin ${tool.version})`
|
|
112
|
-
);
|
|
113
|
-
} catch {
|
|
114
|
-
check(
|
|
115
|
-
false,
|
|
116
|
-
`tool ${tool.name}`,
|
|
117
|
-
`pinned ${tool.version} but not installed`
|
|
118
|
-
);
|
|
119
|
-
}
|
|
100
|
+
for (const step of planToolchain(tools, probe, "remote")) {
|
|
101
|
+
check(step.action === "present", `tool ${step.name}`, step.reason);
|
|
120
102
|
}
|
|
121
103
|
}
|
|
122
104
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Prove at session start that this agent can actually do the work: that the
|
|
3
|
+
# credentials the project needs resolve, and that the CLIs it needs are on PATH.
|
|
4
|
+
#
|
|
5
|
+
# One hook for both because it is one question — can this agent do the work —
|
|
6
|
+
# and an operator reading two separate reports has to assemble the answer
|
|
7
|
+
# themselves. Both halves also share the same failure mode if split: two hooks
|
|
8
|
+
# means two subprocess spawns on every session start, for a check that is silent
|
|
9
|
+
# almost every time.
|
|
10
|
+
#
|
|
11
|
+
# Injects rather than blocks, and that is deliberate. The right response to a
|
|
12
|
+
# missing credential or tool is for the agent to know before it claims work and
|
|
13
|
+
# to route the item to blocked with a reason a human can act on — not for the
|
|
14
|
+
# session to die. A session that cannot reach the vault can still write code,
|
|
15
|
+
# review a PR, or answer a question; killing it would tax every session for a
|
|
16
|
+
# minority need, and a control people route around enforces nothing.
|
|
17
|
+
#
|
|
18
|
+
# The hard gates live where they can afford to be hard: `lisa doctor` and CI
|
|
19
|
+
# run the same two scripts and exit non-zero on them.
|
|
20
|
+
#
|
|
21
|
+
# Silent on success on purpose. A control that announces itself when everything
|
|
22
|
+
# is fine trains its readers to skim past it, and then it is decoration.
|
|
23
|
+
set -uo pipefail
|
|
24
|
+
|
|
25
|
+
INPUT=$(cat 2>/dev/null || true)
|
|
26
|
+
if [ -n "$INPUT" ]; then
|
|
27
|
+
HOOK_EVENT=$(printf '%s' "$INPUT" | jq -r '.hook_event_name // "SessionStart"' 2>/dev/null || echo "SessionStart")
|
|
28
|
+
else
|
|
29
|
+
HOOK_EVENT="SessionStart"
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
ROOT="${CLAUDE_PLUGIN_ROOT:-${PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}}"
|
|
33
|
+
SECRETS="$ROOT/skills/lisa-secrets-access/scripts/preflight-secrets.mjs"
|
|
34
|
+
TOOLS="$ROOT/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs"
|
|
35
|
+
|
|
36
|
+
command -v node >/dev/null 2>&1 || exit 0
|
|
37
|
+
# The hook's whole output is one `jq -n` document. Without jq there is nothing
|
|
38
|
+
# to write, so exiting is the only honest outcome — the alternative is a hook
|
|
39
|
+
# that prints `jq: command not found` and exits 127, which reports a failure
|
|
40
|
+
# from a check designed never to block. The doctor and CI gates run the same
|
|
41
|
+
# two scripts without jq involved.
|
|
42
|
+
command -v jq >/dev/null 2>&1 || exit 0
|
|
43
|
+
|
|
44
|
+
# stderr carries each report; stdout is reserved for the hook's JSON. A script
|
|
45
|
+
# that is absent (partial or older install) or fails to run at all contributes
|
|
46
|
+
# nothing, because a broken hook must not manufacture a finding.
|
|
47
|
+
REPORT=""
|
|
48
|
+
for script in "$SECRETS" "$TOOLS"; do
|
|
49
|
+
[ -f "$script" ] || continue
|
|
50
|
+
# An escape hatch that is honest about what it costs, and scoped to what it
|
|
51
|
+
# names. Set when a surface has no secrets provider by design and the noise is
|
|
52
|
+
# not worth it; the doctor and CI gates are unaffected, so opting out here
|
|
53
|
+
# does not opt out of the check entirely. It must not also silence the
|
|
54
|
+
# TOOLING half — a variable about secrets that hides a missing `gh` or
|
|
55
|
+
# `maestro` is an opt-out from a check nobody opted out of.
|
|
56
|
+
if [ "$script" = "$SECRETS" ] && [ "${LISA_SKIP_SECRETS_PREFLIGHT:-}" = "1" ]; then
|
|
57
|
+
continue
|
|
58
|
+
fi
|
|
59
|
+
OUT=$(node "$script" 2>&1 >/dev/null) || true
|
|
60
|
+
[ -n "$OUT" ] || continue
|
|
61
|
+
[ -n "$REPORT" ] && REPORT+=$'\n\n'
|
|
62
|
+
REPORT+="$OUT"
|
|
63
|
+
done
|
|
64
|
+
|
|
65
|
+
[ -n "$REPORT" ] || exit 0
|
|
66
|
+
|
|
67
|
+
CONTEXT="## Readiness preflight
|
|
68
|
+
|
|
69
|
+
$REPORT"
|
|
70
|
+
|
|
71
|
+
jq -n --arg event "$HOOK_EVENT" --arg ctx "$CONTEXT" \
|
|
72
|
+
'{"hookSpecificOutput": {"hookEventName": $event, "additionalContext": $ctx}}'
|
|
@@ -422,14 +422,93 @@ The verdict ladder is:
|
|
|
422
422
|
- `READY_WITH_WARNINGS` — no `FAIL`, but one or more `WARN`.
|
|
423
423
|
- `NOT_READY` — one or more `FAIL`.
|
|
424
424
|
|
|
425
|
+
## Gate configuration
|
|
426
|
+
|
|
427
|
+
```sh
|
|
428
|
+
node scripts/lisa-gates.mjs validate # shape + unknown gate ids
|
|
429
|
+
node scripts/lisa-gates.mjs list --moment=pull-request
|
|
430
|
+
node scripts/lisa-gates.mjs contexts # branch-protection contexts
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
A gate is a **property** — *credential leakage* — not a tool. `gitleaks` is one way to prove
|
|
434
|
+
that property, and which way is the project's choice: each gate names a task in the project's own
|
|
435
|
+
runner, so swapping the tool changes one line of project config and nothing in Lisa.
|
|
436
|
+
|
|
437
|
+
`validate` refuses an unknown gate id rather than ignoring it. A misspelled `credential-leakge`
|
|
438
|
+
would otherwise read as an enabled guarantee and run nothing at all — the same silent-hole shape
|
|
439
|
+
as a skipped required check. Gates a project invents carry an `x-` prefix, which Lisa runs without
|
|
440
|
+
pretending to understand.
|
|
441
|
+
|
|
442
|
+
`contexts` is the value that replaces a hand-transcribed branch-protection list. It is scoped to
|
|
443
|
+
one environment: a gate required before a production deploy is **not** thereby a merge blocker on a
|
|
444
|
+
pull request, and collapsing the two would promote every deploy-time gate into branch protection.
|
|
445
|
+
|
|
446
|
+
When reconciling a ruleset against it, pass `--previous=` with any label retired in the last
|
|
447
|
+
release. Downstream repositories call the shared workflow unpinned, so a renamed job reaches every
|
|
448
|
+
repository before any of them has reconciled — and a required context that never reports leaves
|
|
449
|
+
pull requests waiting indefinitely. The fastest way out of that is deleting the requirement, which
|
|
450
|
+
is how a rename ends up removing a guarantee. Emitting both labels for one release avoids it.
|
|
451
|
+
|
|
452
|
+
### Reconciling against the live ruleset
|
|
453
|
+
|
|
454
|
+
`contexts` says what the repository *should* require. Reconciliation asks whether GitHub agrees:
|
|
455
|
+
|
|
456
|
+
```sh
|
|
457
|
+
node scripts/lisa-reconcile-policy.mjs --dry-run # read-only: what would change
|
|
458
|
+
node scripts/lisa-reconcile-policy.mjs --on-drift=report # report, write nothing
|
|
459
|
+
node scripts/lisa-reconcile-policy.mjs --on-drift=block # exit 1 on any drift, for CI
|
|
460
|
+
node scripts/lisa-reconcile-policy.mjs # honors policy.on_drift
|
|
461
|
+
node scripts/lisa-reconcile-policy.mjs --previous="🧽 Lint" # keep a renamed context required
|
|
462
|
+
node scripts/lisa-reconcile-policy.mjs --prune # also remove EXTRA contexts
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
It reads the live ruleset and repository settings through `gh`, compares them against the derived
|
|
466
|
+
contexts and the `policy` block, and reports three sets: **MISSING** (declared, not live), **EXTRA**
|
|
467
|
+
(live, not declared), **MATCHED**. `--dry-run` never writes, whatever `policy.on_drift` says.
|
|
468
|
+
|
|
469
|
+
Three behaviors need an operator to understand them before reading a report.
|
|
470
|
+
|
|
471
|
+
**1. `UNPROVEN` is not a pass.** If `gh` is missing, unauthenticated, or the API errors — a private
|
|
472
|
+
repository on a plan without rulesets answers `403` — the verdict is `UNPROVEN` and the exit code is
|
|
473
|
+
`2`. It is neither of its neighbours, and the distinction is the same one the secrets preflight
|
|
474
|
+
draws with `unreachable`: reported as clean it is a vacuous green, clean precisely because nothing
|
|
475
|
+
was learned; reported as drift it sends someone to fix a ruleset that may be perfectly correct. The
|
|
476
|
+
drift sets come back `null` rather than empty, because empty is what a clean repository looks like.
|
|
477
|
+
`on_drift` does not soften this — not even `report` — because `on_drift` decides what to do about a
|
|
478
|
+
drift that was *measured*, and here nothing was. Map it to doctor's `WARN`/`FAIL` on the same rule
|
|
479
|
+
as any other unavailable check surface: never `PASS`.
|
|
480
|
+
|
|
481
|
+
**2. An `EXTRA` context is reported, never removed.** Lisa does not own the whole required list.
|
|
482
|
+
`SonarCloud Code Analysis`, `GitGuardian Security Checks`, and `CodeRabbit` are posted by external
|
|
483
|
+
apps that no gates block declares, so `contextsFor` cannot derive them and every one of them is
|
|
484
|
+
EXTRA by construction. Under `repair` the script therefore ADDS what is missing and leaves what is
|
|
485
|
+
extra alone, naming each one. Removing them requires `--prune`, and the right way to clear the list
|
|
486
|
+
is one at a time: each EXTRA context is either an app to keep, or a gate that belongs in
|
|
487
|
+
`.lisa.config.json` — decide which before pruning anything.
|
|
488
|
+
|
|
489
|
+
**3. Keep both names during a rename.** `--previous=<old label>` requires the old and the new
|
|
490
|
+
context simultaneously for one release. Without it the reconciliation reports the still-live old
|
|
491
|
+
context as EXTRA (and `--prune` would delete it) while in-flight pull requests wait on a context
|
|
492
|
+
that will never report again.
|
|
493
|
+
|
|
494
|
+
Repair writes exactly two things: required contexts on a ruleset, and repository settings. Policy
|
|
495
|
+
carried by the *shape* of a rule — linear history, signed commits, force-push and deletion
|
|
496
|
+
protection, conversation resolution — is compared here and repaired by
|
|
497
|
+
`scripts/lisa-github-rulesets.sh`, which owns rule construction; the reconciler reports those and
|
|
498
|
+
names that script rather than reshaping rules it did not build. When more than one ruleset requires
|
|
499
|
+
status checks it refuses to guess which owns the derived contexts and asks for `--ruleset=<name>`,
|
|
500
|
+
because writing to the wrong one enforces a context under a different ref-name condition.
|
|
501
|
+
|
|
425
502
|
## Secrets configuration
|
|
426
503
|
|
|
427
504
|
Run the secrets health checks through the skill that owns the contract, rather than reimplementing
|
|
428
505
|
any part of it here:
|
|
429
506
|
|
|
430
507
|
```sh
|
|
431
|
-
node .claude/skills/lisa-secrets-access/scripts/validate-config.mjs
|
|
432
|
-
node .claude/skills/lisa-secrets-access/scripts/
|
|
508
|
+
node .claude/skills/lisa-secrets-access/scripts/validate-config.mjs # shape
|
|
509
|
+
node .claude/skills/lisa-secrets-access/scripts/preflight-secrets.mjs # credential readiness
|
|
510
|
+
node .claude/skills/lisa-setup-remote-env/scripts/preflight-tools.mjs # tooling readiness
|
|
511
|
+
node .claude/skills/lisa-secrets-access/scripts/doctor-secrets.mjs # health
|
|
433
512
|
```
|
|
434
513
|
|
|
435
514
|
Run the validator first. It checks only the *shape* of the `secrets`, `remoteEnv`, and
|
|
@@ -438,6 +517,33 @@ would otherwise surface somewhere unhelpful: a container failing mid-setup, a sc
|
|
|
438
517
|
never fires, a dispatch naming a surface nobody provisioned. The health check then asks whether the
|
|
439
518
|
credentials actually resolve.
|
|
440
519
|
|
|
520
|
+
The preflight in the middle is the same check the SessionStart hook runs, and it is here for the
|
|
521
|
+
half the hook deliberately cannot do. The hook injects its verdict into an agent's context and
|
|
522
|
+
never blocks — killing a session over a credential it may not need would tax every session for a
|
|
523
|
+
minority need, and a control people route around enforces nothing. Doctor is where the same
|
|
524
|
+
verdict is allowed to be a non-zero exit.
|
|
525
|
+
|
|
526
|
+
It reports three outcomes, and the third carries the weight. `ok` and `missing` are self-evident.
|
|
527
|
+
**`unreachable`** means the provider itself could not be asked — no CLI, no bootstrap token, an API
|
|
528
|
+
that errored — and it is neither of its neighbours. Reported as `ok` it would be a vacuous green,
|
|
529
|
+
clean precisely because nothing was learned. Reported as `missing` it would blame the vault for a
|
|
530
|
+
fault in this machine's access and send someone to grant a credential that was never absent. Both
|
|
531
|
+
fail; they differ in what they tell the reader to fix.
|
|
532
|
+
|
|
533
|
+
The tooling preflight asks the same question about CLIs, and it is a *caller* of
|
|
534
|
+
`planToolchain` rather than a second implementation — that distinction is the point.
|
|
535
|
+
`verify-remote-env.mjs` previously ran its own toolchain loop which never consulted
|
|
536
|
+
`minVersion`, so a container verified clean against a `node` older than the manifest
|
|
537
|
+
demanded while the plan-side check rejected exactly that. One function now answers the
|
|
538
|
+
question everywhere.
|
|
539
|
+
|
|
540
|
+
It needs only two verdicts. A local binary cannot fail to be asked the way a vault can,
|
|
541
|
+
and the analogous trap — a tool present at an unparseable version — already fails closed
|
|
542
|
+
upstream, because an unknown version loses every `minVersion` comparison. What it does add
|
|
543
|
+
is a split credentials have no equivalent for: a missing tool with a pinned, checksummed
|
|
544
|
+
`install` entry is something Lisa can place itself, so it reports an action and exits zero,
|
|
545
|
+
while a tool nothing can provision blocks and exits non-zero.
|
|
546
|
+
|
|
441
547
|
It reports without ever printing a value, and compares two copies of the same credential by digest.
|
|
442
548
|
Map its findings into the doctor's own verdicts: `error` → `FAIL`, `warn` → `WARN`.
|
|
443
549
|
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prove, before an agent starts work, that the credentials it needs resolve.
|
|
3
|
+
*
|
|
4
|
+
* The contract has always claimed this. `secrets.require` is documented as an
|
|
5
|
+
* assertion — "a listed name that does not resolve is a startup error, not a
|
|
6
|
+
* late surprise" — and that sentence is the literal text of an error message in
|
|
7
|
+
* `doctor-secrets.mjs`. But nothing ran it at startup. The only caller of
|
|
8
|
+
* either presence check was a bash line inside `lisa-doctor`'s SKILL.md, which
|
|
9
|
+
* an agent had to read and choose to execute. The guarantee was prose; the
|
|
10
|
+
* plumbing to deliver it did not exist.
|
|
11
|
+
*
|
|
12
|
+
* This module is the missing caller, and it answers the question the gate
|
|
13
|
+
* actually asks. `AGENTS.md` requires intake to establish that the factory has
|
|
14
|
+
* the tooling "*and provable access to that tooling*" before accepting work. A
|
|
15
|
+
* credential that resolves nowhere is precisely a failure of provable access,
|
|
16
|
+
* and discovering it forty minutes into a build — after a ticket is claimed and
|
|
17
|
+
* a branch is cut — converts a clean refusal into abandoned half-work.
|
|
18
|
+
*
|
|
19
|
+
* **Three verdicts, and the third is the point.** `ok` and `missing` are
|
|
20
|
+
* obvious. `unreachable` is what happens when the provider itself cannot be
|
|
21
|
+
* asked: no CLI installed, no bootstrap token, an API that errored. That state
|
|
22
|
+
* must never collapse into either neighbour. Folded into `ok` it is a vacuous
|
|
23
|
+
* green — the check reports clean precisely when it learned nothing, which is
|
|
24
|
+
* the failure mode that lets a suppressed error read as a measured zero. Folded
|
|
25
|
+
* into `missing` it blames the vault for a fault in the caller's access, and
|
|
26
|
+
* sends whoever reads it to grant a credential that was never absent.
|
|
27
|
+
*
|
|
28
|
+
* Both non-ok verdicts fail. They are distinguished in the *message*, not the
|
|
29
|
+
* exit code, because the remediation differs completely: `missing` means extend
|
|
30
|
+
* the grant in the vault, `unreachable` means fix this machine's access to it.
|
|
31
|
+
* @module preflight-secrets
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { execFileSync } from "node:child_process";
|
|
35
|
+
import { realpathSync } from "node:fs";
|
|
36
|
+
import { fileURLToPath } from "node:url";
|
|
37
|
+
|
|
38
|
+
import { fetchAll } from "./providers.mjs";
|
|
39
|
+
import { readConfig } from "./surfaces.mjs";
|
|
40
|
+
import { readMaterialized } from "./resolve-secret.mjs";
|
|
41
|
+
import {
|
|
42
|
+
routingFloorReasons,
|
|
43
|
+
SUBSTITUTE_SUBSTRATES,
|
|
44
|
+
} from "./routing-floor.mjs";
|
|
45
|
+
|
|
46
|
+
/** Verdicts this check can reach, worst last. */
|
|
47
|
+
export const VERDICTS = ["ok", "missing", "unreachable"];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The names that must resolve on this surface, and why each is required.
|
|
51
|
+
*
|
|
52
|
+
* The floor is *unioned* with `require` rather than checked against it. A
|
|
53
|
+
* project should not have to restate `GH_TOKEN` to be protected by a rule its
|
|
54
|
+
* own `tracker: "github"` already implies, and a required set that depends on
|
|
55
|
+
* someone having typed it is exactly the hand-maintained list this replaces.
|
|
56
|
+
*
|
|
57
|
+
* `require` keeps its second job untouched. It still narrows resolution — a
|
|
58
|
+
* name outside it cannot be fetched — and the floor deliberately does not feed
|
|
59
|
+
* that. Unioning the floor into `cfg.require` would switch narrowing *on* for
|
|
60
|
+
* every project that never opted into it, and every credential they resolve
|
|
61
|
+
* today that is not in the floor would start throwing.
|
|
62
|
+
* @param {object} cfg Resolved configuration.
|
|
63
|
+
* @returns {Array<{name: string, reasons: string[]}>} Required names, sorted.
|
|
64
|
+
*/
|
|
65
|
+
export function requiredNames(cfg) {
|
|
66
|
+
const reasons = routingFloorReasons(cfg.routing ?? {});
|
|
67
|
+
const declared = cfg.require ?? [];
|
|
68
|
+
const names = [...new Set([...(cfg.requiredFloor ?? []), ...declared])];
|
|
69
|
+
return names
|
|
70
|
+
.sort((left, right) => left.localeCompare(right))
|
|
71
|
+
.map(name => ({ name, reasons: whyRequired(name, reasons, declared) }));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Why one name is required, never empty.
|
|
76
|
+
*
|
|
77
|
+
* `report` renders this as "NAME — required because <reasons>", so an empty
|
|
78
|
+
* list prints a sentence that stops mid-clause and tells the operator nothing
|
|
79
|
+
* about which line of config to change. A name can reach the required set
|
|
80
|
+
* through the floor without a routing reason — anything that hands `preflight`
|
|
81
|
+
* a `requiredFloor` it did not derive from `routing` does exactly that — so the
|
|
82
|
+
* renderer needs a cause it can always print.
|
|
83
|
+
* @param {string} name The credential name.
|
|
84
|
+
* @param {Record<string, string[]>} reasons Routing-derived causes.
|
|
85
|
+
* @param {string[]} declared Names listed in `secrets.require`.
|
|
86
|
+
* @returns {string[]} At least one reason.
|
|
87
|
+
*/
|
|
88
|
+
function whyRequired(name, reasons, declared) {
|
|
89
|
+
const routingReasons = Object.hasOwn(reasons, name) ? reasons[name] : [];
|
|
90
|
+
const causes = [
|
|
91
|
+
...(routingReasons ?? []),
|
|
92
|
+
...(declared.includes(name) ? ["declared in secrets.require"] : []),
|
|
93
|
+
];
|
|
94
|
+
return causes.length
|
|
95
|
+
? causes
|
|
96
|
+
: ["it is in this project's resolved secrets floor"];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Check every required credential against the resolution ladder.
|
|
101
|
+
*
|
|
102
|
+
* Values are never returned, printed, or written anywhere by this function. It
|
|
103
|
+
* reports names and whether each resolved, which is all a readiness check needs
|
|
104
|
+
* and the only shape safe to surface into an agent's context.
|
|
105
|
+
* @param {object} [cfg] Resolved configuration.
|
|
106
|
+
* @param {Function} [fetch] Provider view factory, injected for tests.
|
|
107
|
+
* @param {Function} [materialized] Materialized view factory, for tests.
|
|
108
|
+
* @param {Record<string, string|undefined>} [env] Environment to inspect.
|
|
109
|
+
* @param {Function} [probe] Substrate probe runner, injected for tests.
|
|
110
|
+
* @returns {{verdict: string, required: Array<{name: string, reasons: string[]}>, missing: Array<{name: string, reasons: string[]}>, reason: string|null}}
|
|
111
|
+
*/
|
|
112
|
+
export function preflight(
|
|
113
|
+
cfg = readConfig(),
|
|
114
|
+
fetch = fetchAll,
|
|
115
|
+
materialized = readMaterialized,
|
|
116
|
+
env = process.env,
|
|
117
|
+
probe = runProbe
|
|
118
|
+
) {
|
|
119
|
+
const required = requiredNames(cfg);
|
|
120
|
+
if (!required.length) {
|
|
121
|
+
return { verdict: "ok", required, missing: [], reason: null };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const file = safely(() => materialized(cfg)) ?? new Map();
|
|
125
|
+
|
|
126
|
+
// Env and the materialized file are consulted before the provider, and a
|
|
127
|
+
// required name satisfied by either needs no provider call at all. A session
|
|
128
|
+
// whose credentials are already exported is the common case on CI, and paying
|
|
129
|
+
// a network round-trip to confirm what is already in the environment would
|
|
130
|
+
// make the cheapest surface the slowest.
|
|
131
|
+
const unresolvedLocally = required.filter(
|
|
132
|
+
({ name }) =>
|
|
133
|
+
!present(env[name]) && !file.get(name) && !substrateSatisfies(name, probe)
|
|
134
|
+
);
|
|
135
|
+
if (!unresolvedLocally.length) {
|
|
136
|
+
return { verdict: "ok", required, missing: [], reason: null };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let provider;
|
|
140
|
+
try {
|
|
141
|
+
provider = fetch(cfg);
|
|
142
|
+
} catch (err) {
|
|
143
|
+
return {
|
|
144
|
+
verdict: "unreachable",
|
|
145
|
+
required,
|
|
146
|
+
missing: unresolvedLocally,
|
|
147
|
+
reason: err.message,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const missing = unresolvedLocally.filter(
|
|
152
|
+
({ name }) => !present(provider.get(name)?.value)
|
|
153
|
+
);
|
|
154
|
+
return {
|
|
155
|
+
verdict: missing.length ? "missing" : "ok",
|
|
156
|
+
required,
|
|
157
|
+
missing,
|
|
158
|
+
reason: null,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Render a verdict for whoever has to act on it.
|
|
164
|
+
*
|
|
165
|
+
* Written for a non-technical operator standing at the gate, per `AGENTS.md`:
|
|
166
|
+
* everything crossing a gate outward must be readable by the person being asked
|
|
167
|
+
* to decide. So each line names the credential, why it is required, and which
|
|
168
|
+
* of the two remedies applies.
|
|
169
|
+
* @param {object} result A {@link preflight} result.
|
|
170
|
+
* @param {object} cfg Resolved configuration.
|
|
171
|
+
* @returns {string} Operator-readable report, empty when nothing needs saying.
|
|
172
|
+
*/
|
|
173
|
+
export function report(result, cfg) {
|
|
174
|
+
if (result.verdict === "ok") return "";
|
|
175
|
+
|
|
176
|
+
const lines = [];
|
|
177
|
+
if (result.verdict === "unreachable") {
|
|
178
|
+
lines.push(
|
|
179
|
+
`Secrets preflight could NOT be completed on surface "${cfg.surface}".`,
|
|
180
|
+
`The "${cfg.provider}" provider could not be reached, so nothing is`,
|
|
181
|
+
`known about any credential — this is not a report that they are fine.`,
|
|
182
|
+
``,
|
|
183
|
+
` reason: ${result.reason}`,
|
|
184
|
+
``,
|
|
185
|
+
`Fix this machine's access to the provider (bootstrap token, CLI`,
|
|
186
|
+
`install, network), then start a new session. Do not treat the`,
|
|
187
|
+
`credentials below as verified:`
|
|
188
|
+
);
|
|
189
|
+
} else {
|
|
190
|
+
lines.push(
|
|
191
|
+
`Secrets preflight FAILED on surface "${cfg.surface}".`,
|
|
192
|
+
`These credentials are required and resolve nowhere — not in the`,
|
|
193
|
+
`environment, not materialized, not in the "${cfg.provider}" grant.`,
|
|
194
|
+
`Extend the grant in the vault (or correct the routing that requires`,
|
|
195
|
+
`them), then start a new session:`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
lines.push(``);
|
|
199
|
+
for (const { name, reasons } of result.missing) {
|
|
200
|
+
lines.push(` ${name} — required because ${reasons.join("; ")}`);
|
|
201
|
+
}
|
|
202
|
+
lines.push(
|
|
203
|
+
``,
|
|
204
|
+
`Work needing one of these cannot be completed. Route the item to`,
|
|
205
|
+
`blocked with this reason rather than claiming it and stopping partway.`
|
|
206
|
+
);
|
|
207
|
+
return lines.join("\n");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Whether an alternative substrate already provides this capability.
|
|
212
|
+
* @param {string} name Required credential name.
|
|
213
|
+
* @param {Function} probe Probe runner, injected for tests.
|
|
214
|
+
* @returns {boolean} True when the substrate answered successfully.
|
|
215
|
+
*/
|
|
216
|
+
function substrateSatisfies(name, probe) {
|
|
217
|
+
const substrate = SUBSTITUTE_SUBSTRATES[name];
|
|
218
|
+
return substrate ? probe(substrate) : false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* How long a substrate probe may take before it counts as no answer.
|
|
223
|
+
*
|
|
224
|
+
* `gh auth status` contacts GitHub, and this runs on the session-start path.
|
|
225
|
+
* Unbounded, a hung connection holds the session open for as long as the child
|
|
226
|
+
* lives — the `catch` cannot help, because it is only reached once the call
|
|
227
|
+
* returns. Five seconds is far beyond a healthy round trip and far below the
|
|
228
|
+
* point where a person concludes the agent is broken.
|
|
229
|
+
*/
|
|
230
|
+
const PROBE_TIMEOUT_MS = 5000;
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Run a substrate probe, treating any failure as "did not satisfy".
|
|
234
|
+
*
|
|
235
|
+
* Output is discarded rather than captured. The probe's job is to answer a
|
|
236
|
+
* yes/no question with its exit status, and a command that can print a
|
|
237
|
+
* credential is one whose stdout should never enter this process.
|
|
238
|
+
*
|
|
239
|
+
* A timeout throws, which the catch maps to false — the fail-closed answer. The
|
|
240
|
+
* credential is then checked against the environment, the materialized file and
|
|
241
|
+
* the provider as if no substrate existed, so a slow network costs a stricter
|
|
242
|
+
* check rather than a wrong verdict.
|
|
243
|
+
*
|
|
244
|
+
* Exported so the bound itself can be proved against a real slow command. A
|
|
245
|
+
* timeout that only exists as an option object is a claim; one that has been
|
|
246
|
+
* watched cut a hanging child off is a control.
|
|
247
|
+
* @param {{command: string, args: string[]}} substrate Probe definition.
|
|
248
|
+
* @returns {boolean} True when the command exited zero.
|
|
249
|
+
*/
|
|
250
|
+
export function runProbe(substrate) {
|
|
251
|
+
try {
|
|
252
|
+
execFileSync(substrate.command, substrate.args, {
|
|
253
|
+
stdio: "ignore",
|
|
254
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
255
|
+
});
|
|
256
|
+
return true;
|
|
257
|
+
} catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Whether a resolved value counts as present.
|
|
264
|
+
* @param {unknown} value Candidate value.
|
|
265
|
+
* @returns {boolean} True when it is a non-blank string.
|
|
266
|
+
*/
|
|
267
|
+
function present(value) {
|
|
268
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Run a thunk, treating any throw as "no view available".
|
|
273
|
+
*
|
|
274
|
+
* The materialized file is optional on every surface and absent on most, so a
|
|
275
|
+
* failure to read it is not a preflight failure — the provider is still
|
|
276
|
+
* authoritative. Only the provider's own unavailability is a verdict.
|
|
277
|
+
* @param {Function} thunk Work to attempt.
|
|
278
|
+
* @returns {*} The result, or undefined when it threw.
|
|
279
|
+
*/
|
|
280
|
+
function safely(thunk) {
|
|
281
|
+
try {
|
|
282
|
+
return thunk();
|
|
283
|
+
} catch {
|
|
284
|
+
return undefined;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* CLI entry point. Prints the report and exits non-zero on any failure.
|
|
290
|
+
*/
|
|
291
|
+
function main() {
|
|
292
|
+
const cfg = readConfig();
|
|
293
|
+
const result = preflight(cfg);
|
|
294
|
+
const text = report(result, cfg);
|
|
295
|
+
if (text) console.error(text);
|
|
296
|
+
if (result.verdict !== "ok") process.exit(1);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Whether this module is the entry point node was asked to run.
|
|
301
|
+
*
|
|
302
|
+
* Both sides are realpath'd rather than compared as text: `import.meta.url` is
|
|
303
|
+
* the resolved path while `process.argv[1]` is whatever the caller typed, so a
|
|
304
|
+
* symlinked path — every git worktree, and every `/tmp` path on macOS — makes a
|
|
305
|
+
* raw comparison false. The module then loads, runs nothing, and exits 0, which
|
|
306
|
+
* is a readiness check reporting clean because it never ran. Same rule and same
|
|
307
|
+
* reasoning as `scripts/lib/invoked-as-script.mjs`, written out here because a
|
|
308
|
+
* plugin payload has no `./lib/` to import from.
|
|
309
|
+
* @param {string} moduleUrl This module's own `import.meta.url`.
|
|
310
|
+
* @param {string} [argv1] Entry path; defaults to `process.argv[1]`.
|
|
311
|
+
* @returns {boolean} Whether the CLI body should run.
|
|
312
|
+
*/
|
|
313
|
+
function invokedAsScript(moduleUrl, argv1 = process.argv[1]) {
|
|
314
|
+
if (!argv1) return false;
|
|
315
|
+
try {
|
|
316
|
+
return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
|
|
317
|
+
} catch {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (invokedAsScript(import.meta.url)) {
|
|
323
|
+
main();
|
|
324
|
+
}
|