failproofai 1.0.8 → 1.0.9-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +5 -5
- package/.next/standalone/.next/prerender-manifest.json +4 -4
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
- package/.next/standalone/.next/server/app/policies/page.js +4 -4
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings/page/build-manifest.json +2 -2
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +8 -8
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
- package/.next/standalone/.next/server/chunks/_09dz7xv._.js +20 -20
- package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
- package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
- package/.next/standalone/.next/server/chunks/_1ek68ln._.js +2 -2
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_01r25oi._.js +2 -2
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_09z9-p7._.js +2 -2
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/src_hooks_1aveq0u._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__03m2iw6._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__04usis8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__056wjo4._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0n0xg95._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rwtwpm._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__12e7nhs._.js → [root-of-the-server]__1-bm5zf._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11mayhe._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15578wp._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1m_svbe._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1pprgri._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1q4p5b8._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_042cgl1._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_0bqoto4._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_11ilmst._.js +23 -0
- package/.next/standalone/.next/server/chunks/ssr/_1gb0ifp._.js +3 -3
- package/.next/standalone/.next/server/chunks/ssr/{_0-vcssj._.js → _1jatlmf._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +3 -3
- package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9sni._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h4._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_posthog-node_dist_entrypoints_index_node_mjs_11bnuzn._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_effective-reviewers_ts_1h4wtvo._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_semantic_pack-policies_ts_0gh_bu_._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +23 -23
- package/.next/standalone/.next/static/chunks/{06jxjnw8bfczc.js → 0542cuk_va8g1.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0wbjy0zo-m7is.js → 08ckontevw-0w.js} +1 -1
- package/.next/standalone/.next/static/chunks/0pefjgvu4x-sx.js +1 -0
- package/.next/standalone/.next/static/chunks/{21uv-uusw329x.js → 1-d48e9k-7e1a.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0m-9d6yn9hx4j.js → 1r74hmr1wm_sd.js} +1 -1
- package/.next/standalone/.next/static/chunks/1uec0n0lcx5h1.js +1 -0
- package/.next/standalone/.next/static/chunks/21-pqc3-l9ak2.js +6 -0
- package/.next/standalone/.next/static/chunks/21qlo00jis4w-.js +1 -0
- package/.next/standalone/.next/static/chunks/2n00c3zqyloy9.js +1 -0
- package/.next/standalone/.next/static/chunks/{0bhidk90e-07f.css → 3_e_wg5_oay87.css} +1 -1
- package/.next/standalone/.next/static/chunks/{3c3qbmosdjl6w.js → 3tv107_for_n7.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2qdpj67x6ifk_.js → 3vp54x0dyrrm6.js} +1 -1
- package/.next/standalone/.next/static/chunks/{turbopack-38nzuvp95_2rv.js → turbopack-11dn2pegmg9_9.js} +1 -1
- package/.next/standalone/app/actions/get-jev-config.ts +2 -2
- package/.next/standalone/app/actions/update-jev-config.ts +12 -9
- package/.next/standalone/app/components/jev-notices.tsx +10 -10
- package/.next/standalone/app/settings/jev-panel.tsx +11 -11
- package/.next/standalone/fp-cloud-cli/CHANGELOG.md +2 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/output.py +26 -0
- package/.next/standalone/fp-cloud-cli/tests/test_output.py +27 -0
- package/.next/standalone/fp-cloud-cli/uv.lock +3 -3
- package/.next/standalone/hermes-plugin/README.md +18 -8
- package/.next/standalone/node_modules/@next/env/package.json +1 -1
- package/.next/standalone/node_modules/next/dist/build/swc/index.js +1 -1
- package/.next/standalone/node_modules/next/dist/compiled/next-server/pages-turbo.runtime.prod.js +1 -1
- package/.next/standalone/node_modules/next/dist/lib/patch-incorrect-lockfile.js +3 -3
- package/.next/standalone/node_modules/next/dist/server/config.js +1 -1
- package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-turbopack.js +2 -2
- package/.next/standalone/node_modules/next/dist/server/dev/hot-reloader-webpack.js +1 -1
- package/.next/standalone/node_modules/next/dist/server/lib/app-info-log.js +1 -1
- package/.next/standalone/node_modules/next/dist/server/lib/start-server.js +1 -1
- package/.next/standalone/node_modules/next/dist/telemetry/anonymous-meta.js +1 -1
- package/.next/standalone/node_modules/next/dist/telemetry/events/swc-load-failure.js +1 -1
- package/.next/standalone/node_modules/next/dist/telemetry/events/version.js +2 -2
- package/.next/standalone/node_modules/next/package.json +10 -10
- package/.next/standalone/package.json +12 -12
- package/.next/standalone/sdk/python/uv.lock +18 -18
- package/.next/standalone/server.js +1 -1
- package/bin/failproofai.mjs +33 -7
- package/dist/cli.mjs +1385 -1158
- package/dist/worker.mjs +59 -375
- package/hermes-plugin/README.md +18 -8
- package/package.json +12 -12
- package/scripts/build-policy-pack.mjs +6 -9
- package/src/hooks/cloud-connection.ts +12 -6
- package/src/hooks/custom-hooks-loader.ts +1 -1
- package/src/hooks/effective-reviewers.ts +50 -43
- package/src/hooks/handler.ts +25 -16
- package/src/hooks/hermes-update.ts +74 -0
- package/src/hooks/hook-activity-store.ts +3 -3
- package/src/hooks/integrations.ts +472 -68
- package/src/hooks/jev-activity.ts +8 -3
- package/src/hooks/jev-cli.ts +17 -17
- package/src/hooks/jev-cloud-connection.ts +4 -4
- package/src/hooks/manager.ts +3 -2
- package/src/hooks/pack-cli.ts +24 -23
- package/src/hooks/pack-manifest.ts +25 -2
- package/src/hooks/pack-store.ts +2 -2
- package/src/hooks/policy-authority.ts +51 -32
- package/src/hooks/policy-evaluator.ts +5 -5
- package/src/hooks/policy-registry.ts +5 -7
- package/src/hooks/policy-reviewability.ts +30 -12
- package/src/hooks/policy-types.ts +4 -4
- package/src/hooks/safe-config-write.ts +119 -0
- package/src/hooks/semantic/combine.ts +13 -13
- package/src/hooks/semantic/evaluator.ts +5 -5
- package/src/hooks/semantic/jev-client.ts +1 -1
- package/src/hooks/semantic/jev-config.ts +21 -12
- package/src/hooks/semantic/jev-review.ts +3 -3
- package/src/hooks/semantic/jev-stats.ts +17 -17
- package/src/hooks/semantic/pack-policies.ts +48 -48
- package/src/hooks/semantic/policies.ts +13 -425
- package/src/hooks/semantic/precondition-names.ts +1 -1
- package/src/hooks/semantic/preconditions.ts +1 -1
- package/src/hooks/semantic/types.ts +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__13t1zkw._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_13kfn90._.js +0 -23
- package/.next/standalone/.next/static/chunks/06dnzbolj00mc.js +0 -6
- package/.next/standalone/.next/static/chunks/16f3fa-lx38hk.js +0 -1
- package/.next/standalone/.next/static/chunks/2g2tki08kdhie.js +0 -1
- package/.next/standalone/.next/static/chunks/3-nbtkhg9y-1j.js +0 -1
- package/.next/standalone/.next/static/chunks/355km0ihuqo1p.js +0 -1
- /package/.next/standalone/.next/static/{5yKM8NoTOpUI0MoQl8eo5 → ozuWoyIwOTqeUUjKqOKiH}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{5yKM8NoTOpUI0MoQl8eo5 → ozuWoyIwOTqeUUjKqOKiH}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{5yKM8NoTOpUI0MoQl8eo5 → ozuWoyIwOTqeUUjKqOKiH}/_ssgManifest.js +0 -0
|
@@ -14,14 +14,18 @@ import {
|
|
|
14
14
|
existsSync,
|
|
15
15
|
lstatSync,
|
|
16
16
|
mkdirSync,
|
|
17
|
+
readlinkSync,
|
|
18
|
+
realpathSync,
|
|
17
19
|
renameSync,
|
|
18
20
|
rmSync,
|
|
21
|
+
symlinkSync,
|
|
19
22
|
unlinkSync,
|
|
20
23
|
} from "node:fs";
|
|
21
|
-
import { resolve, dirname } from "node:path";
|
|
24
|
+
import { resolve, dirname, basename } from "node:path";
|
|
22
25
|
import { fileURLToPath } from "node:url";
|
|
23
26
|
import { homedir } from "node:os";
|
|
24
27
|
import { parseDocument, type Document } from "yaml";
|
|
28
|
+
import { writeConfigFileAtomic } from "./safe-config-write";
|
|
25
29
|
import { listHermesProfiles, hermesRoot } from "../../lib/hermes-profiles";
|
|
26
30
|
import { listOpenClawProfiles } from "../../lib/openclaw-profiles";
|
|
27
31
|
import {
|
|
@@ -62,32 +66,82 @@ import {
|
|
|
62
66
|
|
|
63
67
|
// ── Generic helpers ─────────────────────────────────────────────────────────
|
|
64
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The error every config reader throws for a file it cannot read faithfully.
|
|
71
|
+
*
|
|
72
|
+
* These files belong to the user's agent. A config that exists but does not
|
|
73
|
+
* parse is never treated as empty: writing our entries into an "empty" document
|
|
74
|
+
* would replace every other setting in it, and the agent would come back without
|
|
75
|
+
* its model, keys or channels. Refusing leaves the file exactly as the user has
|
|
76
|
+
* it and says which file and why.
|
|
77
|
+
*/
|
|
78
|
+
export class UnreadableAgentConfigError extends Error {
|
|
79
|
+
constructor(readonly path: string, reason: string) {
|
|
80
|
+
super(
|
|
81
|
+
`Refusing to modify ${path}: ${reason}. ` +
|
|
82
|
+
`failproofai never rewrites a config it could not parse — fix or move the file, then retry.`,
|
|
83
|
+
);
|
|
84
|
+
this.name = "UnreadableAgentConfigError";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
65
88
|
function readJsonFile(path: string): Record<string, unknown> {
|
|
66
89
|
if (!existsSync(path)) return {};
|
|
67
|
-
|
|
68
|
-
|
|
90
|
+
let raw: string;
|
|
91
|
+
try {
|
|
92
|
+
raw = readFileSync(path, "utf8");
|
|
93
|
+
} catch (err) {
|
|
94
|
+
throw new UnreadableAgentConfigError(path, `it could not be read (${err instanceof Error ? err.message : String(err)})`);
|
|
95
|
+
}
|
|
96
|
+
if (raw.trim() === "") return {};
|
|
97
|
+
let parsed: unknown;
|
|
98
|
+
try {
|
|
99
|
+
parsed = JSON.parse(raw);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
throw new UnreadableAgentConfigError(path, `it is not valid JSON (${err instanceof Error ? err.message : String(err)})`);
|
|
102
|
+
}
|
|
103
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
104
|
+
throw new UnreadableAgentConfigError(path, "its top level is not a JSON object");
|
|
105
|
+
}
|
|
106
|
+
return parsed as Record<string, unknown>;
|
|
69
107
|
}
|
|
70
108
|
|
|
71
109
|
function writeJsonFile(path: string, data: Record<string, unknown>): void {
|
|
72
|
-
|
|
73
|
-
writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
110
|
+
writeConfigFileAtomic(path, JSON.stringify(data, null, 2) + "\n");
|
|
74
111
|
}
|
|
75
112
|
|
|
76
113
|
/** Read a YAML file as a `Document` so writes round-trip the user's other keys +
|
|
77
|
-
* comments (a plain parse→stringify would strip comments).
|
|
78
|
-
*
|
|
79
|
-
*
|
|
114
|
+
* comments (a plain parse→stringify would strip comments). Missing or empty →
|
|
115
|
+
* an empty Document; unreadable or unparseable → {@link UnreadableAgentConfigError},
|
|
116
|
+
* never an empty Document that the next write would put in the user's place.
|
|
117
|
+
* Used by the Hermes integration, whose config lives in `~/.hermes/config.yaml`. */
|
|
80
118
|
function readYamlDoc(path: string): Document {
|
|
119
|
+
if (!existsSync(path)) return parseDocument("");
|
|
120
|
+
let text: string;
|
|
121
|
+
try {
|
|
122
|
+
text = readFileSync(path, "utf8");
|
|
123
|
+
} catch (err) {
|
|
124
|
+
throw new UnreadableAgentConfigError(path, `it could not be read (${err instanceof Error ? err.message : String(err)})`);
|
|
125
|
+
}
|
|
126
|
+
const doc = parseDocument(text);
|
|
127
|
+
if (doc.errors.length > 0) {
|
|
128
|
+
const first = doc.errors[0]?.message.split("\n")[0] ?? "syntax error";
|
|
129
|
+
throw new UnreadableAgentConfigError(path, `it does not parse as YAML (${first})`);
|
|
130
|
+
}
|
|
131
|
+
return doc;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** {@link readYamlDoc} for read-only callers (status, health): an unreadable file is `null`. */
|
|
135
|
+
function inspectYamlDoc(path: string): Document | null {
|
|
81
136
|
try {
|
|
82
|
-
return
|
|
137
|
+
return readYamlDoc(path);
|
|
83
138
|
} catch {
|
|
84
|
-
return
|
|
139
|
+
return null;
|
|
85
140
|
}
|
|
86
141
|
}
|
|
87
142
|
|
|
88
143
|
function writeYamlDoc(path: string, doc: Document): void {
|
|
89
|
-
|
|
90
|
-
writeFileSync(path, doc.toString(), "utf8");
|
|
144
|
+
writeConfigFileAtomic(path, doc.toString());
|
|
91
145
|
}
|
|
92
146
|
|
|
93
147
|
function isMarkedHook(hook: unknown): boolean {
|
|
@@ -1124,7 +1178,7 @@ export const opencode: Integration = {
|
|
|
1124
1178
|
// (a) Write the shim file. mkdirSync is recursive so the plugins/ dir
|
|
1125
1179
|
// is created on first install.
|
|
1126
1180
|
mkdirSync(dirname(pluginPath), { recursive: true });
|
|
1127
|
-
|
|
1181
|
+
writeConfigFileAtomic(pluginPath, buildOpenCodePluginShim(binaryPath, effectiveScope));
|
|
1128
1182
|
|
|
1129
1183
|
// (b) Merge our entry into the plugin array idempotently. Replace any
|
|
1130
1184
|
// existing failproofai-marked entry; otherwise append.
|
|
@@ -1388,9 +1442,13 @@ function makePiProjectRelativeEntry(extPath: string): string {
|
|
|
1388
1442
|
// Hermes loads trusted Python plugins from `<HERMES_HOME>/plugins/<name>/`.
|
|
1389
1443
|
// The shipped plugin registers Hermes-native hooks and talks directly to the
|
|
1390
1444
|
// local failproofaid socket, avoiding a fresh CLI process per event. Config is
|
|
1391
|
-
// YAML and profile-scoped; install
|
|
1445
|
+
// YAML and profile-scoped; install LINKS every profile's `plugins/failproofai`
|
|
1446
|
+
// to the package's `hermes-plugin/` (so npm upgrades apply with no reinstall,
|
|
1447
|
+
// like OpenClaw's `plugins.load.paths`), falling back to a marked copy, and
|
|
1392
1448
|
// adds `failproofai` to `plugins.enabled`. Legacy shell-hook entries are removed
|
|
1393
|
-
// during migration so one tool call is never evaluated twice
|
|
1449
|
+
// during migration so one tool call is never evaluated twice — and because
|
|
1450
|
+
// config.yaml shell hooks never reach Hermes cron jobs: each cron fire builds
|
|
1451
|
+
// its own hook scope, which discovered plugins join and config hooks do not.
|
|
1394
1452
|
|
|
1395
1453
|
/** One hook entry as stored under a `hooks:` event key in config.yaml. */
|
|
1396
1454
|
interface HermesHookEntry {
|
|
@@ -1409,38 +1467,160 @@ interface HermesPluginsConfig {
|
|
|
1409
1467
|
const HERMES_PLUGIN_ID = "failproofai";
|
|
1410
1468
|
const HERMES_PLUGIN_MARKER = ".failproofai-managed";
|
|
1411
1469
|
const HERMES_PLUGIN_FILES = ["plugin.yaml", "__init__.py", "client.py", "ledger.py"] as const;
|
|
1470
|
+
/** Basename of the plugin directory inside the npm package. */
|
|
1471
|
+
const HERMES_PLUGIN_SOURCE_DIR = "hermes-plugin";
|
|
1412
1472
|
|
|
1413
1473
|
function getHermesPluginSourcePath(): string {
|
|
1414
1474
|
const fromEnv = process.env.FAILPROOFAI_PACKAGE_ROOT;
|
|
1415
|
-
if (fromEnv) return resolve(fromEnv,
|
|
1416
|
-
return
|
|
1475
|
+
if (fromEnv) return resolve(fromEnv, HERMES_PLUGIN_SOURCE_DIR);
|
|
1476
|
+
return findHermesPluginSourcePath(dirname(fileURLToPath(import.meta.url)));
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/**
|
|
1480
|
+
* The package's `hermes-plugin/`, found by walking up from `startDir`.
|
|
1481
|
+
*
|
|
1482
|
+
* The CLI entry point sets `FAILPROOFAI_PACKAGE_ROOT`, so this is only the
|
|
1483
|
+
* fallback — but a fixed number of parents is right for exactly one layout:
|
|
1484
|
+
* three from `src/hooks/` is the package root, three from the bundled
|
|
1485
|
+
* `dist/cli.mjs` is the package's PARENT. Walking up to the first directory
|
|
1486
|
+
* that really holds the plugin's manifest works from source, from `dist/`, and
|
|
1487
|
+
* from the dashboard's standalone build alike. When none is found the old
|
|
1488
|
+
* answer is returned, so the install still fails loudly on a missing asset.
|
|
1489
|
+
*/
|
|
1490
|
+
export function findHermesPluginSourcePath(startDir: string): string {
|
|
1491
|
+
let dir = resolve(startDir);
|
|
1492
|
+
for (let i = 0; i < 8; i++) {
|
|
1493
|
+
const candidate = resolve(dir, HERMES_PLUGIN_SOURCE_DIR);
|
|
1494
|
+
if (existsSync(resolve(candidate, "plugin.yaml"))) return candidate;
|
|
1495
|
+
const parent = dirname(dir);
|
|
1496
|
+
if (parent === dir) break;
|
|
1497
|
+
dir = parent;
|
|
1498
|
+
}
|
|
1499
|
+
return resolve(startDir, "..", "..", HERMES_PLUGIN_SOURCE_DIR);
|
|
1417
1500
|
}
|
|
1418
1501
|
|
|
1419
1502
|
export function hermesPluginPathForSettings(settingsPath: string): string {
|
|
1420
1503
|
return resolve(dirname(settingsPath), "plugins", HERMES_PLUGIN_ID);
|
|
1421
1504
|
}
|
|
1422
1505
|
|
|
1423
|
-
|
|
1424
|
-
|
|
1506
|
+
/** `existsSync` follows symlinks, so a dangling link reads as absent. This does not. */
|
|
1507
|
+
function pathPresent(path: string): boolean {
|
|
1425
1508
|
try {
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
return HERMES_PLUGIN_FILES.every((file) => existsSync(resolve(pluginPath, file)));
|
|
1509
|
+
lstatSync(path);
|
|
1510
|
+
return true;
|
|
1429
1511
|
} catch {
|
|
1430
1512
|
return false;
|
|
1431
1513
|
}
|
|
1432
1514
|
}
|
|
1433
1515
|
|
|
1434
|
-
function
|
|
1435
|
-
|
|
1516
|
+
function hasAllHermesPluginFiles(dir: string): boolean {
|
|
1517
|
+
return HERMES_PLUGIN_FILES.every((file) => existsSync(resolve(dir, file)));
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/**
|
|
1521
|
+
* Whether `dir` is a FailproofAI `hermes-plugin` directory (or was one: a link
|
|
1522
|
+
* whose target vanished during an npm upgrade or uninstall is still ours).
|
|
1523
|
+
* The basename is the package layout; the manifest name confirms it when the
|
|
1524
|
+
* target is readable, so a link to someone else's plugin is never claimed.
|
|
1525
|
+
*/
|
|
1526
|
+
function isFailproofaiHermesPluginSource(dir: string): boolean {
|
|
1527
|
+
if (basename(dir) !== HERMES_PLUGIN_SOURCE_DIR) return false;
|
|
1528
|
+
if (!existsSync(dir)) return true;
|
|
1436
1529
|
try {
|
|
1437
|
-
|
|
1530
|
+
const manifest = readFileSync(resolve(dir, "plugin.yaml"), "utf8");
|
|
1531
|
+
return /^name:\s*["']?failproofai["']?\s*$/m.test(manifest);
|
|
1438
1532
|
} catch {
|
|
1439
1533
|
return false;
|
|
1440
1534
|
}
|
|
1441
1535
|
}
|
|
1442
1536
|
|
|
1443
|
-
function
|
|
1537
|
+
function samePath(a: string, b: string): boolean {
|
|
1538
|
+
if (resolve(a) === resolve(b)) return true;
|
|
1539
|
+
try {
|
|
1540
|
+
return realpathSync(a) === realpathSync(b);
|
|
1541
|
+
} catch {
|
|
1542
|
+
return false;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
/**
|
|
1547
|
+
* What occupies `<profile>/plugins/failproofai`, and whether FailproofAI owns it.
|
|
1548
|
+
*
|
|
1549
|
+
* • `link` — a symlink into a FailproofAI package's `hermes-plugin/` (the
|
|
1550
|
+
* current install shape: npm upgrades apply with no reinstall).
|
|
1551
|
+
* • `copy` — a copied directory carrying the `.failproofai-managed`
|
|
1552
|
+
* marker (1.0.6–1.0.8 installs, and the fallback where a
|
|
1553
|
+
* symlink cannot be created).
|
|
1554
|
+
* • `foreign` — anything else: an unmarked directory, a file, or a symlink
|
|
1555
|
+
* somewhere that is not a FailproofAI plugin. Never touched.
|
|
1556
|
+
*/
|
|
1557
|
+
type HermesPluginState =
|
|
1558
|
+
| { kind: "absent" }
|
|
1559
|
+
| { kind: "link"; target: string; complete: boolean; current: boolean }
|
|
1560
|
+
| { kind: "copy"; complete: boolean }
|
|
1561
|
+
| { kind: "foreign" };
|
|
1562
|
+
|
|
1563
|
+
function hermesPluginState(settingsPath: string): HermesPluginState {
|
|
1564
|
+
const pluginPath = hermesPluginPathForSettings(settingsPath);
|
|
1565
|
+
let isLink: boolean;
|
|
1566
|
+
let isDirectory: boolean;
|
|
1567
|
+
try {
|
|
1568
|
+
const stat = lstatSync(pluginPath);
|
|
1569
|
+
isLink = stat.isSymbolicLink();
|
|
1570
|
+
isDirectory = stat.isDirectory();
|
|
1571
|
+
} catch {
|
|
1572
|
+
return { kind: "absent" };
|
|
1573
|
+
}
|
|
1574
|
+
if (isLink) {
|
|
1575
|
+
let target: string;
|
|
1576
|
+
try {
|
|
1577
|
+
target = resolve(dirname(pluginPath), readlinkSync(pluginPath));
|
|
1578
|
+
} catch {
|
|
1579
|
+
return { kind: "foreign" };
|
|
1580
|
+
}
|
|
1581
|
+
if (!isFailproofaiHermesPluginSource(target)) return { kind: "foreign" };
|
|
1582
|
+
return {
|
|
1583
|
+
kind: "link",
|
|
1584
|
+
target,
|
|
1585
|
+
complete: hasAllHermesPluginFiles(target),
|
|
1586
|
+
current: samePath(target, getHermesPluginSourcePath()),
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
if (isDirectory && existsSync(resolve(pluginPath, HERMES_PLUGIN_MARKER))) {
|
|
1590
|
+
return { kind: "copy", complete: hasAllHermesPluginFiles(pluginPath) };
|
|
1591
|
+
}
|
|
1592
|
+
return { kind: "foreign" };
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
function hasManagedHermesPlugin(settingsPath: string): boolean {
|
|
1596
|
+
const state = hermesPluginState(settingsPath);
|
|
1597
|
+
return (state.kind === "link" || state.kind === "copy") && state.complete;
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
/** Test seam: lets a test make symlink creation fail to exercise the copy fallback. */
|
|
1601
|
+
export interface HermesPluginInstallDeps {
|
|
1602
|
+
symlink?: (target: string, path: string) => void;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
function defaultHermesSymlink(target: string, path: string): void {
|
|
1606
|
+
// A junction needs no privilege on Windows; the type is ignored elsewhere.
|
|
1607
|
+
symlinkSync(target, path, process.platform === "win32" ? "junction" : "dir");
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/**
|
|
1611
|
+
* Put the plugin at `<profile>/plugins/failproofai`, preferring a symlink to
|
|
1612
|
+
* the package's own `hermes-plugin/` so every npm upgrade updates it in place —
|
|
1613
|
+
* the same reason OpenClaw's config points into the package. Hermes' plugin
|
|
1614
|
+
* scan uses `Path.is_dir()`, which follows symlinks, so the link is discovered
|
|
1615
|
+
* like a directory. Falls back to a marked copy when a link cannot be created.
|
|
1616
|
+
*
|
|
1617
|
+
* Only a path FailproofAI owns (a marked copy, or a link into a FailproofAI
|
|
1618
|
+
* `hermes-plugin/`) is ever replaced; anything else throws.
|
|
1619
|
+
*/
|
|
1620
|
+
export function installHermesPlugin(
|
|
1621
|
+
settingsPath: string,
|
|
1622
|
+
deps: HermesPluginInstallDeps = {},
|
|
1623
|
+
): "linked" | "copied" | "unchanged" {
|
|
1444
1624
|
const source = getHermesPluginSourcePath();
|
|
1445
1625
|
for (const file of HERMES_PLUGIN_FILES) {
|
|
1446
1626
|
if (!existsSync(resolve(source, file))) {
|
|
@@ -1449,50 +1629,93 @@ function installHermesPlugin(settingsPath: string): void {
|
|
|
1449
1629
|
}
|
|
1450
1630
|
|
|
1451
1631
|
const destination = hermesPluginPathForSettings(settingsPath);
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
}
|
|
1632
|
+
const existing = hermesPluginState(settingsPath);
|
|
1633
|
+
if (existing.kind === "foreign") {
|
|
1634
|
+
throw new Error(
|
|
1635
|
+
`Refusing to overwrite an unmanaged Hermes plugin at ${destination}. ` +
|
|
1636
|
+
`Move it aside or install FailproofAI under a different Hermes profile.`,
|
|
1637
|
+
);
|
|
1459
1638
|
}
|
|
1639
|
+
if (existing.kind === "link" && existing.current) return "unchanged";
|
|
1460
1640
|
|
|
1461
1641
|
mkdirSync(dirname(destination), { recursive: true });
|
|
1462
1642
|
const suffix = `${process.pid}-${Date.now()}`;
|
|
1463
1643
|
const temporary = `${destination}.install-${suffix}`;
|
|
1464
|
-
|
|
1465
|
-
let
|
|
1644
|
+
|
|
1645
|
+
let linked = false;
|
|
1466
1646
|
try {
|
|
1647
|
+
(deps.symlink ?? defaultHermesSymlink)(source, temporary);
|
|
1648
|
+
linked = true;
|
|
1649
|
+
} catch {
|
|
1650
|
+
rmSync(temporary, { recursive: true, force: true });
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
if (!linked) {
|
|
1467
1654
|
mkdirSync(temporary, { recursive: true });
|
|
1468
|
-
|
|
1469
|
-
|
|
1655
|
+
try {
|
|
1656
|
+
for (const file of HERMES_PLUGIN_FILES) {
|
|
1657
|
+
cpSync(resolve(source, file), resolve(temporary, file));
|
|
1658
|
+
}
|
|
1659
|
+
writeFileSync(
|
|
1660
|
+
resolve(temporary, HERMES_PLUGIN_MARKER),
|
|
1661
|
+
"Managed by failproofai. Remove with `failproofai policies --uninstall --cli hermes`.\n",
|
|
1662
|
+
{ encoding: "utf8", mode: 0o600 },
|
|
1663
|
+
);
|
|
1664
|
+
} catch (err) {
|
|
1665
|
+
rmSync(temporary, { recursive: true, force: true });
|
|
1666
|
+
throw err;
|
|
1470
1667
|
}
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// A link replacing a link is one atomic rename. Anything replacing a
|
|
1671
|
+
// directory (or a link replaced by a directory) cannot rename over it, so
|
|
1672
|
+
// the old entry is moved aside first and restored if the swap fails.
|
|
1673
|
+
if (linked && (existing.kind === "absent" || existing.kind === "link")) {
|
|
1674
|
+
try {
|
|
1675
|
+
renameSync(temporary, destination);
|
|
1676
|
+
return "linked";
|
|
1677
|
+
} catch (err) {
|
|
1678
|
+
// Windows cannot always rename a junction over another junction; the
|
|
1679
|
+
// backup swap below moves the old link aside first, so try that before
|
|
1680
|
+
// giving up. With nothing there to move aside, the rename is the error.
|
|
1681
|
+
if (existing.kind !== "link") {
|
|
1682
|
+
rmSync(temporary, { force: true });
|
|
1683
|
+
throw err;
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
const backup = `${destination}.backup-${suffix}`;
|
|
1689
|
+
let movedExisting = false;
|
|
1690
|
+
try {
|
|
1691
|
+
if (pathPresent(destination)) {
|
|
1477
1692
|
renameSync(destination, backup);
|
|
1478
1693
|
movedExisting = true;
|
|
1479
1694
|
}
|
|
1480
1695
|
renameSync(temporary, destination);
|
|
1696
|
+
// fs.rm never follows a symlink, so a backed-up link removes only itself.
|
|
1481
1697
|
if (movedExisting) rmSync(backup, { recursive: true, force: true });
|
|
1482
1698
|
} catch (err) {
|
|
1483
1699
|
rmSync(temporary, { recursive: true, force: true });
|
|
1484
|
-
if (movedExisting && !
|
|
1700
|
+
if (movedExisting && !pathPresent(destination) && pathPresent(backup)) {
|
|
1485
1701
|
renameSync(backup, destination);
|
|
1486
1702
|
}
|
|
1487
1703
|
throw err;
|
|
1488
1704
|
}
|
|
1705
|
+
return linked ? "linked" : "copied";
|
|
1489
1706
|
}
|
|
1490
1707
|
|
|
1491
1708
|
function removeManagedHermesPlugin(settingsPath: string): boolean {
|
|
1492
1709
|
const pluginPath = hermesPluginPathForSettings(settingsPath);
|
|
1710
|
+
const state = hermesPluginState(settingsPath);
|
|
1711
|
+
if (state.kind === "link") {
|
|
1712
|
+
// Only the link goes; the package directory it points at is npm's.
|
|
1713
|
+
unlinkSync(pluginPath);
|
|
1714
|
+
return true;
|
|
1715
|
+
}
|
|
1493
1716
|
// The marker is the ownership boundary. Remove even an incomplete managed
|
|
1494
|
-
//
|
|
1495
|
-
if (
|
|
1717
|
+
// copy so an interrupted install can always be repaired or uninstalled.
|
|
1718
|
+
if (state.kind !== "copy") return false;
|
|
1496
1719
|
rmSync(pluginPath, { recursive: true, force: true });
|
|
1497
1720
|
return true;
|
|
1498
1721
|
}
|
|
@@ -1527,11 +1750,13 @@ function removeLegacyHermesHooks(doc: Document): number {
|
|
|
1527
1750
|
function hermesConfigState(settingsPath: string): {
|
|
1528
1751
|
pluginEnabled: boolean;
|
|
1529
1752
|
legacyShellHookPresent: boolean;
|
|
1753
|
+
unreadable?: boolean;
|
|
1530
1754
|
} {
|
|
1531
1755
|
if (!existsSync(settingsPath)) {
|
|
1532
1756
|
return { pluginEnabled: false, legacyShellHookPresent: false };
|
|
1533
1757
|
}
|
|
1534
|
-
const doc =
|
|
1758
|
+
const doc = inspectYamlDoc(settingsPath);
|
|
1759
|
+
if (!doc) return { pluginEnabled: false, legacyShellHookPresent: false, unreadable: true };
|
|
1535
1760
|
const js = (doc.toJS() ?? {}) as {
|
|
1536
1761
|
hooks?: Record<string, HermesHookEntry[]>;
|
|
1537
1762
|
plugins?: HermesPluginsConfig;
|
|
@@ -1549,13 +1774,188 @@ function hermesConfigState(settingsPath: string): {
|
|
|
1549
1774
|
};
|
|
1550
1775
|
}
|
|
1551
1776
|
|
|
1777
|
+
/**
|
|
1778
|
+
* Enable the native plugin in one profile's config and drop FailproofAI's
|
|
1779
|
+
* legacy shell hooks. A native plugin and the legacy shell bridge running
|
|
1780
|
+
* together would evaluate every event twice. Only failproofai-owned shell
|
|
1781
|
+
* entries are removed; unrelated operator hooks remain intact.
|
|
1782
|
+
*/
|
|
1783
|
+
function enableHermesPluginInDoc(doc: Document): void {
|
|
1784
|
+
removeLegacyHermesHooks(doc);
|
|
1785
|
+
|
|
1786
|
+
const js = (doc.toJS() ?? {}) as { plugins?: HermesPluginsConfig };
|
|
1787
|
+
const plugins: HermesPluginsConfig =
|
|
1788
|
+
js.plugins && typeof js.plugins === "object" ? js.plugins : {};
|
|
1789
|
+
const enabled = Array.isArray(plugins.enabled) ? [...plugins.enabled] : [];
|
|
1790
|
+
if (!enabled.includes(HERMES_PLUGIN_ID)) enabled.push(HERMES_PLUGIN_ID);
|
|
1791
|
+
plugins.enabled = enabled;
|
|
1792
|
+
if (Array.isArray(plugins.disabled)) {
|
|
1793
|
+
plugins.disabled = plugins.disabled.filter((name) => name !== HERMES_PLUGIN_ID);
|
|
1794
|
+
if (plugins.disabled.length === 0) delete plugins.disabled;
|
|
1795
|
+
}
|
|
1796
|
+
doc.set("plugins", plugins);
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
export type HermesMigrationStatus =
|
|
1800
|
+
/** Brought to the linked-plugin state by this run. */
|
|
1801
|
+
| "migrated"
|
|
1802
|
+
/** Already linked to this package, enabled, and free of shell hooks. */
|
|
1803
|
+
| "current"
|
|
1804
|
+
/** No FailproofAI integration in this profile — left alone. */
|
|
1805
|
+
| "untouched"
|
|
1806
|
+
/** Needs migrating, but the daemon cannot serve the native plugin. Nothing changed. */
|
|
1807
|
+
| "blocked"
|
|
1808
|
+
/** Could not be migrated (unmanaged plugin in the way, unreadable config, I/O error). */
|
|
1809
|
+
| "failed";
|
|
1810
|
+
|
|
1811
|
+
export interface HermesProfileMigration {
|
|
1812
|
+
name: string;
|
|
1813
|
+
home: string;
|
|
1814
|
+
status: HermesMigrationStatus;
|
|
1815
|
+
detail: string;
|
|
1816
|
+
/** Legacy config.yaml shell hooks are still in place after this run. */
|
|
1817
|
+
legacyShellHooksRemain: boolean;
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
/**
|
|
1821
|
+
* The upgrade half `failproofai update` owes Hermes: bring every profile that
|
|
1822
|
+
* already uses FailproofAI (legacy shell hooks, a copied plugin, or a link) to
|
|
1823
|
+
* the linked-plugin state. Profiles with no FailproofAI integration are never
|
|
1824
|
+
* touched — an update must not opt a profile in.
|
|
1825
|
+
*
|
|
1826
|
+
* `daemonSupportsPolicyEvaluation` is asked at most once, and only when some
|
|
1827
|
+
* profile needs changing. When it says no, NOTHING is changed: removing the
|
|
1828
|
+
* shell hooks in favour of a plugin whose daemon cannot answer would fail every
|
|
1829
|
+
* tool call closed (the same gate `policies --install` applies).
|
|
1830
|
+
*/
|
|
1831
|
+
export async function migrateHermesProfiles(opts: {
|
|
1832
|
+
daemonSupportsPolicyEvaluation: () => Promise<boolean>;
|
|
1833
|
+
installDeps?: HermesPluginInstallDeps;
|
|
1834
|
+
}): Promise<HermesProfileMigration[]> {
|
|
1835
|
+
const results: HermesProfileMigration[] = [];
|
|
1836
|
+
let daemonOk: boolean | undefined;
|
|
1837
|
+
for (const profile of listHermesProfiles()) {
|
|
1838
|
+
if (!existsSync(profile.home)) continue;
|
|
1839
|
+
const settingsPath = resolve(profile.home, "config.yaml");
|
|
1840
|
+
const pluginPath = hermesPluginPathForSettings(settingsPath);
|
|
1841
|
+
const config = hermesConfigState(settingsPath);
|
|
1842
|
+
const plugin = hermesPluginState(settingsPath);
|
|
1843
|
+
const ours = plugin.kind === "link" || plugin.kind === "copy";
|
|
1844
|
+
const base = { name: profile.name, home: profile.home };
|
|
1845
|
+
const legacy = config.legacyShellHookPresent;
|
|
1846
|
+
|
|
1847
|
+
// A config.yaml that does not parse is never edited. Whether it is ours to
|
|
1848
|
+
// report is read from the raw text: it cannot be parsed to find out.
|
|
1849
|
+
if (config.unreadable) {
|
|
1850
|
+
let raw = "";
|
|
1851
|
+
try {
|
|
1852
|
+
raw = readFileSync(settingsPath, "utf8");
|
|
1853
|
+
} catch {
|
|
1854
|
+
raw = "";
|
|
1855
|
+
}
|
|
1856
|
+
const mentionsUs = ours || raw.includes(FAILPROOFAI_HOOK_MARKER) || /failproofai/.test(raw);
|
|
1857
|
+
results.push(
|
|
1858
|
+
mentionsUs
|
|
1859
|
+
? {
|
|
1860
|
+
...base,
|
|
1861
|
+
status: "failed",
|
|
1862
|
+
detail: `${settingsPath} does not parse as YAML; left untouched`,
|
|
1863
|
+
legacyShellHooksRemain: /--hook\s+\S+\s+--cli\s+hermes|__failproofai_hook__/.test(raw),
|
|
1864
|
+
}
|
|
1865
|
+
: { ...base, status: "untouched", detail: "no failproofai integration", legacyShellHooksRemain: false },
|
|
1866
|
+
);
|
|
1867
|
+
continue;
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
if (!legacy && !ours && !config.pluginEnabled) {
|
|
1871
|
+
results.push({ ...base, status: "untouched", detail: "no failproofai integration", legacyShellHooksRemain: false });
|
|
1872
|
+
continue;
|
|
1873
|
+
}
|
|
1874
|
+
if (plugin.kind === "link" && plugin.current && plugin.complete && config.pluginEnabled && !legacy) {
|
|
1875
|
+
results.push({ ...base, status: "current", detail: "already linked", legacyShellHooksRemain: false });
|
|
1876
|
+
continue;
|
|
1877
|
+
}
|
|
1878
|
+
if (plugin.kind === "foreign") {
|
|
1879
|
+
results.push({
|
|
1880
|
+
...base,
|
|
1881
|
+
status: "failed",
|
|
1882
|
+
detail:
|
|
1883
|
+
`an unmanaged plugin occupies ${pluginPath}` +
|
|
1884
|
+
(legacy ? "; shell hooks left in place (cron jobs are not checked)" : ""),
|
|
1885
|
+
legacyShellHooksRemain: legacy,
|
|
1886
|
+
});
|
|
1887
|
+
continue;
|
|
1888
|
+
}
|
|
1889
|
+
if (existsSync(settingsPath)) {
|
|
1890
|
+
const errors = parseDocument(readFileSync(settingsPath, "utf8")).errors;
|
|
1891
|
+
if (errors.length > 0) {
|
|
1892
|
+
results.push({
|
|
1893
|
+
...base,
|
|
1894
|
+
status: "failed",
|
|
1895
|
+
detail: `${settingsPath} does not parse as YAML; left untouched`,
|
|
1896
|
+
legacyShellHooksRemain: legacy,
|
|
1897
|
+
});
|
|
1898
|
+
continue;
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
if (daemonOk === undefined) daemonOk = await opts.daemonSupportsPolicyEvaluation();
|
|
1903
|
+
if (!daemonOk) {
|
|
1904
|
+
results.push({
|
|
1905
|
+
...base,
|
|
1906
|
+
status: "blocked",
|
|
1907
|
+
detail: legacy
|
|
1908
|
+
? "daemon lacks native policy evaluation; shell hooks left in place (cron jobs are not checked)"
|
|
1909
|
+
: "daemon lacks native policy evaluation; the plugin here cannot get verdicts",
|
|
1910
|
+
legacyShellHooksRemain: legacy,
|
|
1911
|
+
});
|
|
1912
|
+
continue;
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
try {
|
|
1916
|
+
// Plugin first, config second: the shell hooks are only removed once the
|
|
1917
|
+
// plugin that replaces them is on disk.
|
|
1918
|
+
const mode = installHermesPlugin(settingsPath, opts.installDeps);
|
|
1919
|
+
const doc = readYamlDoc(settingsPath);
|
|
1920
|
+
enableHermesPluginInDoc(doc);
|
|
1921
|
+
writeYamlDoc(settingsPath, doc);
|
|
1922
|
+
const from = legacy
|
|
1923
|
+
? "shell hooks"
|
|
1924
|
+
: plugin.kind === "copy"
|
|
1925
|
+
? "copied plugin"
|
|
1926
|
+
: plugin.kind === "link"
|
|
1927
|
+
? "old plugin link"
|
|
1928
|
+
: "enabled but missing plugin";
|
|
1929
|
+
const to = mode === "copied" ? "plugin copy (symlink not possible here)" : "linked plugin";
|
|
1930
|
+
results.push({ ...base, status: "migrated", detail: `${from} → ${to}`, legacyShellHooksRemain: false });
|
|
1931
|
+
} catch (err) {
|
|
1932
|
+
results.push({
|
|
1933
|
+
...base,
|
|
1934
|
+
status: "failed",
|
|
1935
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
1936
|
+
legacyShellHooksRemain: hermesConfigState(settingsPath).legacyShellHookPresent,
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
return results;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1552
1943
|
export interface HermesProfileHealth {
|
|
1553
1944
|
name: string;
|
|
1554
1945
|
home: string;
|
|
1555
1946
|
settingsPath: string;
|
|
1556
1947
|
pluginInstalled: boolean;
|
|
1948
|
+
/** How the plugin is installed: a link into the package, a copy, or neither. */
|
|
1949
|
+
pluginMode: "link" | "copy" | null;
|
|
1557
1950
|
pluginEnabled: boolean;
|
|
1558
1951
|
legacyShellHookPresent: boolean;
|
|
1952
|
+
/**
|
|
1953
|
+
* Still enforced only by legacy config.yaml shell hooks. Hermes cron jobs run
|
|
1954
|
+
* with none of those, so their tool calls go unchecked.
|
|
1955
|
+
*/
|
|
1956
|
+
cronUnchecked: boolean;
|
|
1957
|
+
/** config.yaml exists but does not parse; failproofai will not touch it. */
|
|
1958
|
+
configUnreadable: boolean;
|
|
1559
1959
|
healthy: boolean;
|
|
1560
1960
|
}
|
|
1561
1961
|
|
|
@@ -1564,15 +1964,20 @@ export function hermesProfileHealth(): HermesProfileHealth[] {
|
|
|
1564
1964
|
return listHermesProfiles().map((profile) => {
|
|
1565
1965
|
const settingsPath = resolve(profile.home, "config.yaml");
|
|
1566
1966
|
const config = hermesConfigState(settingsPath);
|
|
1567
|
-
const
|
|
1967
|
+
const plugin = hermesPluginState(settingsPath);
|
|
1968
|
+
const pluginInstalled = (plugin.kind === "link" || plugin.kind === "copy") && plugin.complete;
|
|
1969
|
+
const pluginWorks = pluginInstalled && config.pluginEnabled;
|
|
1568
1970
|
return {
|
|
1569
1971
|
name: profile.name,
|
|
1570
1972
|
home: profile.home,
|
|
1571
1973
|
settingsPath,
|
|
1572
1974
|
pluginInstalled,
|
|
1975
|
+
pluginMode: plugin.kind === "link" || plugin.kind === "copy" ? plugin.kind : null,
|
|
1573
1976
|
pluginEnabled: config.pluginEnabled,
|
|
1574
1977
|
legacyShellHookPresent: config.legacyShellHookPresent,
|
|
1575
|
-
|
|
1978
|
+
cronUnchecked: config.legacyShellHookPresent && !pluginWorks,
|
|
1979
|
+
configUnreadable: config.unreadable === true,
|
|
1980
|
+
healthy: pluginWorks && !config.legacyShellHookPresent && config.unreadable !== true,
|
|
1576
1981
|
};
|
|
1577
1982
|
});
|
|
1578
1983
|
}
|
|
@@ -1582,13 +1987,28 @@ export function hermesProfileStatusRows(): Array<[string, string]> {
|
|
|
1582
1987
|
return hermesProfileHealth()
|
|
1583
1988
|
.filter((profile) => existsSync(profile.home))
|
|
1584
1989
|
.map((profile) => {
|
|
1990
|
+
const label = "hermes/" + profile.name;
|
|
1991
|
+
if (profile.configUnreadable) {
|
|
1992
|
+
return [label, `UNHEALTHY — ${profile.settingsPath} does not parse; failproofai leaves it untouched until it is fixed`];
|
|
1993
|
+
}
|
|
1994
|
+
if (profile.cronUnchecked) {
|
|
1995
|
+
// Not a cosmetic leftover: the shell-hook integration does not reach
|
|
1996
|
+
// cron jobs at all, so this profile has an unenforced path.
|
|
1997
|
+
return [
|
|
1998
|
+
label,
|
|
1999
|
+
"UNHEALTHY — legacy shell hooks: Hermes cron jobs are not checked. Run `failproofai update`",
|
|
2000
|
+
];
|
|
2001
|
+
}
|
|
1585
2002
|
const problems: string[] = [];
|
|
1586
2003
|
if (!profile.pluginInstalled) problems.push("plugin files missing or incomplete");
|
|
1587
2004
|
if (!profile.pluginEnabled) problems.push("plugin not enabled");
|
|
1588
2005
|
if (profile.legacyShellHookPresent) problems.push("legacy shell hook also present");
|
|
2006
|
+
if (problems.length > 0) return [label, "UNHEALTHY — " + problems.join("; ")];
|
|
1589
2007
|
return [
|
|
1590
|
-
|
|
1591
|
-
|
|
2008
|
+
label,
|
|
2009
|
+
profile.pluginMode === "copy"
|
|
2010
|
+
? "native plugin enabled (copied — `failproofai update` links it so upgrades apply)"
|
|
2011
|
+
: "native plugin enabled",
|
|
1592
2012
|
];
|
|
1593
2013
|
});
|
|
1594
2014
|
}
|
|
@@ -1641,23 +2061,7 @@ export const hermes: Integration = {
|
|
|
1641
2061
|
isFailproofaiHook: isMarkedHook,
|
|
1642
2062
|
|
|
1643
2063
|
writeHookEntries(settings) {
|
|
1644
|
-
|
|
1645
|
-
// A native plugin and the legacy shell bridge running together would
|
|
1646
|
-
// evaluate every event twice. Remove only failproofai-owned shell entries;
|
|
1647
|
-
// unrelated operator hooks remain intact.
|
|
1648
|
-
removeLegacyHermesHooks(doc);
|
|
1649
|
-
|
|
1650
|
-
const js = (doc.toJS() ?? {}) as { plugins?: HermesPluginsConfig };
|
|
1651
|
-
const plugins: HermesPluginsConfig =
|
|
1652
|
-
js.plugins && typeof js.plugins === "object" ? js.plugins : {};
|
|
1653
|
-
const enabled = Array.isArray(plugins.enabled) ? [...plugins.enabled] : [];
|
|
1654
|
-
if (!enabled.includes(HERMES_PLUGIN_ID)) enabled.push(HERMES_PLUGIN_ID);
|
|
1655
|
-
plugins.enabled = enabled;
|
|
1656
|
-
if (Array.isArray(plugins.disabled)) {
|
|
1657
|
-
plugins.disabled = plugins.disabled.filter((name) => name !== HERMES_PLUGIN_ID);
|
|
1658
|
-
if (plugins.disabled.length === 0) delete plugins.disabled;
|
|
1659
|
-
}
|
|
1660
|
-
doc.set("plugins", plugins);
|
|
2064
|
+
enableHermesPluginInDoc(settings as unknown as Document);
|
|
1661
2065
|
},
|
|
1662
2066
|
|
|
1663
2067
|
removeHooksFromFile(settingsPath) {
|