yenop 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +105 -0
- package/README.md +243 -3
- package/dist/adapters/claude-code/hook.d.ts +43 -0
- package/dist/adapters/claude-code/hook.js +101 -0
- package/dist/adapters/claude-code/hook.js.map +1 -0
- package/dist/adapters/claude-code/install.d.ts +23 -0
- package/dist/adapters/claude-code/install.js +86 -0
- package/dist/adapters/claude-code/install.js.map +1 -0
- package/dist/adapters/codex/hook.d.ts +7 -0
- package/dist/adapters/codex/hook.js +134 -0
- package/dist/adapters/codex/hook.js.map +1 -0
- package/dist/adapters/codex/install.d.ts +18 -0
- package/dist/adapters/codex/install.js +66 -0
- package/dist/adapters/codex/install.js.map +1 -0
- package/dist/adapters/cursor/hook.d.ts +25 -0
- package/dist/adapters/cursor/hook.js +228 -0
- package/dist/adapters/cursor/hook.js.map +1 -0
- package/dist/adapters/cursor/install.d.ts +10 -0
- package/dist/adapters/cursor/install.js +57 -0
- package/dist/adapters/cursor/install.js.map +1 -0
- package/dist/adapters/gemini/hook.d.ts +20 -0
- package/dist/adapters/gemini/hook.js +232 -0
- package/dist/adapters/gemini/hook.js.map +1 -0
- package/dist/adapters/gemini/install.d.ts +21 -0
- package/dist/adapters/gemini/install.js +101 -0
- package/dist/adapters/gemini/install.js.map +1 -0
- package/dist/adapters/hooks/pipeline.d.ts +69 -0
- package/dist/adapters/hooks/pipeline.js +131 -0
- package/dist/adapters/hooks/pipeline.js.map +1 -0
- package/dist/adapters/hooks/registry.d.ts +8 -0
- package/dist/adapters/hooks/registry.js +14 -0
- package/dist/adapters/hooks/registry.js.map +1 -0
- package/dist/adapters/mcp/gateway.d.ts +64 -0
- package/dist/adapters/mcp/gateway.js +205 -0
- package/dist/adapters/mcp/gateway.js.map +1 -0
- package/dist/adapters/mcp/run.d.ts +12 -0
- package/dist/adapters/mcp/run.js +86 -0
- package/dist/adapters/mcp/run.js.map +1 -0
- package/dist/adapters/openai-agents/index.d.ts +74 -0
- package/dist/adapters/openai-agents/index.js +139 -0
- package/dist/adapters/openai-agents/index.js.map +1 -0
- package/dist/cli/demo.d.ts +5 -0
- package/dist/cli/demo.js +130 -0
- package/dist/cli/demo.js.map +1 -0
- package/dist/cli/main.d.ts +2 -0
- package/dist/cli/main.js +746 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/core/config.d.ts +50 -0
- package/dist/core/config.js +93 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/engine.d.ts +31 -0
- package/dist/core/engine.js +301 -0
- package/dist/core/engine.js.map +1 -0
- package/dist/core/ids.d.ts +5 -0
- package/dist/core/ids.js +36 -0
- package/dist/core/ids.js.map +1 -0
- package/dist/core/index.d.ts +40 -0
- package/dist/core/index.js +73 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/policy.d.ts +41 -0
- package/dist/core/policy.js +205 -0
- package/dist/core/policy.js.map +1 -0
- package/dist/core/receipts.d.ts +47 -0
- package/dist/core/receipts.js +137 -0
- package/dist/core/receipts.js.map +1 -0
- package/dist/core/report.d.ts +51 -0
- package/dist/core/report.js +125 -0
- package/dist/core/report.js.map +1 -0
- package/dist/core/shell.d.ts +105 -0
- package/dist/core/shell.js +921 -0
- package/dist/core/shell.js.map +1 -0
- package/dist/core/state.d.ts +52 -0
- package/dist/core/state.js +269 -0
- package/dist/core/state.js.map +1 -0
- package/dist/core/telemetry.d.ts +52 -0
- package/dist/core/telemetry.js +150 -0
- package/dist/core/telemetry.js.map +1 -0
- package/dist/core/tools.d.ts +5 -0
- package/dist/core/tools.js +59 -0
- package/dist/core/tools.js.map +1 -0
- package/dist/core/types.d.ts +213 -0
- package/dist/core/types.js +3 -0
- package/dist/core/types.js.map +1 -0
- package/dist/daemon/client.d.ts +34 -0
- package/dist/daemon/client.js +138 -0
- package/dist/daemon/client.js.map +1 -0
- package/dist/daemon/fast.d.ts +9 -0
- package/dist/daemon/fast.js +43 -0
- package/dist/daemon/fast.js.map +1 -0
- package/dist/daemon/server.d.ts +38 -0
- package/dist/daemon/server.js +321 -0
- package/dist/daemon/server.js.map +1 -0
- package/dist/daemon/service.d.ts +26 -0
- package/dist/daemon/service.js +171 -0
- package/dist/daemon/service.js.map +1 -0
- package/dist/viewer/server.d.ts +52 -0
- package/dist/viewer/server.js +184 -0
- package/dist/viewer/server.js.map +1 -0
- package/package.json +59 -7
- package/policies/README.md +58 -0
- package/policies/approve/default.cedar +72 -0
- package/policies/permit/default.cedar +48 -0
- package/policies/schema.cedarschema +104 -0
package/dist/cli/main.js
ADDED
|
@@ -0,0 +1,746 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* The yenop command. Every subcommand loads only what it needs, so `yenop hook claude-code`
|
|
4
|
+
* stays close to bare Node startup and forwards to the daemon in about a millisecond.
|
|
5
|
+
*/
|
|
6
|
+
import { parseArgs } from "node:util";
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { dirname, join, resolve } from "node:path";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
const USAGE = `yenop — agents decide what to do; Yenop decides what is allowed to happen
|
|
12
|
+
|
|
13
|
+
start here:
|
|
14
|
+
yenop init hook the agents in this project (Claude Code, Cursor, Codex, Gemini)
|
|
15
|
+
yenop demo a scripted attack, judged live, in thirty seconds
|
|
16
|
+
yenop viewer --open watch the receipts while an agent works
|
|
17
|
+
yenop report after a week: what would have been stopped, and were the asks right
|
|
18
|
+
|
|
19
|
+
usage:
|
|
20
|
+
yenop init [--user] [--no-hook] [--mode enforce|observe] [--hook http|command] [--agents a,b]
|
|
21
|
+
set up ~/.yenop, start the daemon, install the Claude Code hook
|
|
22
|
+
(project scope: .claude/settings.local.json; --user: ~/.claude/settings.json)
|
|
23
|
+
yenop daemon run|start|stop|status the resident decision service on 127.0.0.1
|
|
24
|
+
yenop service install|uninstall|status|show
|
|
25
|
+
keep the daemon alive under launchd (macOS) or systemd (Linux)
|
|
26
|
+
yenop hook claude-code|cursor|codex|gemini (called by the agent) read its pre-action event on stdin, decide, answer
|
|
27
|
+
yenop mcp [--server NAME] [--on-ask elicit|block|allow] -- CMD ...
|
|
28
|
+
sit between an MCP client and an MCP server; gate every tools/call
|
|
29
|
+
and ask the person through the client when a call needs one
|
|
30
|
+
yenop decide < request.json decide one DecisionRequest from stdin, print the Decision
|
|
31
|
+
yenop check parse and validate every policy in every layer
|
|
32
|
+
yenop schema print the policy vocabulary and an example
|
|
33
|
+
yenop receipts [--last N] [--run ID] [--all] [--verify]
|
|
34
|
+
recent receipts for this project's tenant; --all for every project;
|
|
35
|
+
--verify checks the tamper-evident hash chain
|
|
36
|
+
yenop explain <tool> [json-args] dry-run a tool call against the policies without recording a real step
|
|
37
|
+
yenop status show mode, layers, daemon, and where receipts go for the current project
|
|
38
|
+
yenop playground [dir] create a throwaway project with enforcement on, for testing in Claude Code
|
|
39
|
+
yenop report [--days N] [--all] [--share]
|
|
40
|
+
what your agents did, in aggregate; --share sends the numbers (opt-in telemetry)
|
|
41
|
+
yenop telemetry enable [--endpoint URL]|disable|status|reset
|
|
42
|
+
opt-in, aggregate-only usage statistics; status shows exactly what is sent
|
|
43
|
+
yenop feedback open the discussion board to tell us what you found
|
|
44
|
+
yenop version print the version (include it in a bug report)
|
|
45
|
+
yenop demo a scripted, no-agent walk through what Yenop does, for showing people
|
|
46
|
+
yenop viewer [--port N] [--all] [--open]
|
|
47
|
+
a read-only local web page of the receipts, updating live
|
|
48
|
+
|
|
49
|
+
Mode: "enforce" returns decisions to the runtime; "observe" only records them.
|
|
50
|
+
Set per project in <project>/.yenop/config.json, per machine in ~/.yenop/config.json, or with YENOP_MODE.
|
|
51
|
+
`;
|
|
52
|
+
function home() {
|
|
53
|
+
return process.env["YENOP_HOME"] ?? join(homedir(), ".yenop");
|
|
54
|
+
}
|
|
55
|
+
/** Yenop's own version, from the package.json that ships with the build. */
|
|
56
|
+
const PKG_VERSION = (() => {
|
|
57
|
+
try {
|
|
58
|
+
const p = join(fileURLToPath(import.meta.url), "..", "..", "..", "package.json");
|
|
59
|
+
return JSON.parse(readFileSync(p, "utf8")).version ?? "0.0.0";
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return "0.0.0";
|
|
63
|
+
}
|
|
64
|
+
})();
|
|
65
|
+
async function main(argv) {
|
|
66
|
+
const [cmd, ...rest] = argv;
|
|
67
|
+
switch (cmd) {
|
|
68
|
+
case "version":
|
|
69
|
+
case "--version":
|
|
70
|
+
case "-v":
|
|
71
|
+
process.stdout.write(`yenop ${PKG_VERSION}\n`);
|
|
72
|
+
return 0;
|
|
73
|
+
case "hook": {
|
|
74
|
+
const { hookTranslator, hookRuntimes } = await import("../adapters/hooks/registry.js");
|
|
75
|
+
const t = await hookTranslator(rest[0] ?? "");
|
|
76
|
+
if (!t)
|
|
77
|
+
return fail(`unknown hook runtime: ${rest[0] ?? "(none)"}; known: ${hookRuntimes().join(", ")}`);
|
|
78
|
+
const { runHookWith, readStdin } = await import("../adapters/hooks/pipeline.js");
|
|
79
|
+
try {
|
|
80
|
+
const r = await runHookWith(t, await readStdin());
|
|
81
|
+
if (r.stdout)
|
|
82
|
+
process.stdout.write(r.stdout + "\n");
|
|
83
|
+
if (r.stderr)
|
|
84
|
+
process.stderr.write(r.stderr + "\n");
|
|
85
|
+
return r.exitCode;
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
// A guard that broke must say no, loudly, in the runtime's own language. Observe mode records only.
|
|
89
|
+
if (process.env["YENOP_MODE"] === "observe")
|
|
90
|
+
return 0;
|
|
91
|
+
const r = t.failure(e.message.split("\n")[0] ?? "unknown error");
|
|
92
|
+
if (r.stdout)
|
|
93
|
+
process.stdout.write(r.stdout + "\n");
|
|
94
|
+
if (r.stderr)
|
|
95
|
+
process.stderr.write(r.stderr + "\n");
|
|
96
|
+
return r.exitCode;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
case "mcp": {
|
|
100
|
+
const { parseMcpArgs, runMcpGateway } = await import("../adapters/mcp/run.js");
|
|
101
|
+
const opts = parseMcpArgs(rest);
|
|
102
|
+
opts.cwd = process.cwd();
|
|
103
|
+
return runMcpGateway(opts);
|
|
104
|
+
}
|
|
105
|
+
case "daemon":
|
|
106
|
+
return daemonCommand(rest[0]);
|
|
107
|
+
case "service":
|
|
108
|
+
return serviceCommand(rest[0]);
|
|
109
|
+
case "decide": {
|
|
110
|
+
const { openYenop } = await import("../core/index.js");
|
|
111
|
+
const { readStdin } = await import("../adapters/claude-code/hook.js");
|
|
112
|
+
const req = JSON.parse(await readStdin());
|
|
113
|
+
const y = openYenop(req.cwd !== undefined ? { cwd: req.cwd } : {});
|
|
114
|
+
try {
|
|
115
|
+
process.stdout.write(JSON.stringify(y.decide(req), null, 2) + "\n");
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
y.close();
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
case "explain": {
|
|
123
|
+
const [tool, argsJson] = rest;
|
|
124
|
+
if (!tool)
|
|
125
|
+
return fail("explain needs a tool name");
|
|
126
|
+
const { openYenop, classifyTool } = await import("../core/index.js");
|
|
127
|
+
const args = argsJson ? JSON.parse(argsJson) : {};
|
|
128
|
+
const y = openYenop({ cwd: process.cwd(), dryRun: true });
|
|
129
|
+
try {
|
|
130
|
+
const d = y.decide({ runId: `explain:${Date.now()}`, principal: { runtime: "cli", agent: "explain", user: "you" }, tool: classifyTool(tool), args, cwd: process.cwd() });
|
|
131
|
+
process.stdout.write(`${d.effect.toUpperCase()} ${d.message}\n`);
|
|
132
|
+
if (d.reasons.length)
|
|
133
|
+
process.stdout.write(`reasons: ${d.reasons.join(", ")}\n`);
|
|
134
|
+
if (d.errors.length)
|
|
135
|
+
process.stdout.write(`errors: ${d.errors.join("; ")}\n`);
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
y.close();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
case "check": {
|
|
143
|
+
const { openYenop, loadPolicies, checkPolicies } = await import("../core/index.js");
|
|
144
|
+
const y = openYenop({ cwd: process.cwd(), dryRun: true });
|
|
145
|
+
try {
|
|
146
|
+
if (y.policyError !== undefined) {
|
|
147
|
+
process.stdout.write(`INVALID: ${y.policyError}\n\nUntil this is fixed, Yenop refuses every action in this project${y.config.mode === "observe" ? " (observe mode: recorded, not enforced)" : ""}.\nThe vocabulary: yenop schema\n`);
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
150
|
+
const bundle = loadPolicies(y.config.policyLayers, { disabled: y.config.disabledPolicies });
|
|
151
|
+
checkPolicies(bundle);
|
|
152
|
+
for (const l of bundle.layers)
|
|
153
|
+
process.stdout.write(`${pad(l.name, 9)} ${l.permit} permit, ${l.approve} approval ${l.dir}\n`);
|
|
154
|
+
const off = Object.keys(bundle.disabled);
|
|
155
|
+
process.stdout.write(off.length ? `disabled ${off.join(", ")}\n` : `disabled none\n`);
|
|
156
|
+
for (const w of bundle.warnings)
|
|
157
|
+
process.stdout.write(`warning ${w}\n`);
|
|
158
|
+
process.stdout.write(`ok: every policy matches the vocabulary (policies/schema.cedarschema)\n`);
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
y.close();
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
case "receipts": {
|
|
166
|
+
const { values } = parseArgs({ args: rest, options: { last: { type: "string", default: "20" }, run: { type: "string" }, all: { type: "boolean", default: false }, verify: { type: "boolean", default: false } } });
|
|
167
|
+
const { openYenop, readAllReceipts, answersFor, isOutcome, verifyReceipts } = await import("../core/index.js");
|
|
168
|
+
const y = openYenop({ cwd: process.cwd(), dryRun: true });
|
|
169
|
+
if (values.verify) {
|
|
170
|
+
try {
|
|
171
|
+
const c = verifyReceipts(y.config.receiptsPath);
|
|
172
|
+
if (c.ok)
|
|
173
|
+
process.stdout.write(`receipts intact: ${c.lines} line${c.lines === 1 ? "" : "s"} form an unbroken hash chain (${y.config.receiptsPath})\n`);
|
|
174
|
+
else
|
|
175
|
+
process.stdout.write(`RECEIPTS TAMPERED: the chain breaks at line ${c.brokenAt} of ${c.lines} — ${c.reason}\n(${y.config.receiptsPath})\n`);
|
|
176
|
+
return c.ok ? 0 : 1;
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
y.close();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const everything = readAllReceipts(y.config.receiptsPath);
|
|
184
|
+
const answers = answersFor(everything);
|
|
185
|
+
let rows = everything.filter((r) => !isOutcome(r));
|
|
186
|
+
// One receipts file serves every project on the machine; show this project's tenant unless asked for all.
|
|
187
|
+
const mine = y.config.tenant;
|
|
188
|
+
if (!values.all)
|
|
189
|
+
rows = rows.filter((r) => (typeof r.tenant === "string" ? r.tenant === mine.name : r.tenant.id === mine.id));
|
|
190
|
+
if (values.run)
|
|
191
|
+
rows = rows.filter((r) => r.runId === values.run);
|
|
192
|
+
rows = rows.slice(-Number(values.last));
|
|
193
|
+
if (rows.length === 0) {
|
|
194
|
+
process.stdout.write(`no receipts for tenant "${mine.name}" yet (${y.config.receiptsPath}); use --all to see every project\n`);
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
for (const r of rows) {
|
|
198
|
+
const summary = summarizeArgs(r.args);
|
|
199
|
+
const tenantName = typeof r.tenant === "string" ? r.tenant : r.tenant.name;
|
|
200
|
+
const answer = answers.get(r.id);
|
|
201
|
+
const tag = (r.mode === "observe" ? " (observe)" : r.enforced ? "" : " (not enforced)") + (answer ? ` → ${answer}` : "");
|
|
202
|
+
process.stdout.write(`${r.ts} ${pad(tenantName, 10)} ${pad(r.effect.toUpperCase(), 5)} ${pad(r.tool, 20)} ${summary} [${r.reasons.join(", ")}]${tag}\n`);
|
|
203
|
+
}
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
y.close();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
case "status": {
|
|
211
|
+
const { openYenop } = await import("../core/index.js");
|
|
212
|
+
const { readDaemonInfo, daemonHealthy, isCurrentBuild } = await import("../daemon/client.js");
|
|
213
|
+
const y = openYenop({ cwd: process.cwd(), dryRun: true });
|
|
214
|
+
try {
|
|
215
|
+
const c = y.config;
|
|
216
|
+
process.stdout.write(`mode: ${c.mode}${process.env["YENOP_MODE"] ? " (from YENOP_MODE)" : ""}\n`);
|
|
217
|
+
if (y.policyError !== undefined)
|
|
218
|
+
process.stdout.write(`POLICIES: INVALID, every action is refused until fixed. Run: yenop check\n`);
|
|
219
|
+
process.stdout.write(`tenant: ${c.tenant.name} (${c.tenant.id})\n`);
|
|
220
|
+
for (const l of c.policyLayers)
|
|
221
|
+
process.stdout.write(`${pad(l.name === "baseline" ? "policies:" : "", 10)} ${pad(l.name, 9)} ${l.dir}\n`);
|
|
222
|
+
if (c.disabledPolicies.length)
|
|
223
|
+
process.stdout.write(`disabled: ${c.disabledPolicies.join(", ")}\n`);
|
|
224
|
+
process.stdout.write(`receipts: ${c.receiptsPath}\n`);
|
|
225
|
+
process.stdout.write(`state: ${c.statePath}\n`);
|
|
226
|
+
process.stdout.write(`budgets: ${c.budgets.maxStepsPerRun} steps, ${c.budgets.maxDeniesPerRun} denies per run\n`);
|
|
227
|
+
const info = readDaemonInfo(c.home);
|
|
228
|
+
const h = info ? await daemonHealthy(info) : undefined;
|
|
229
|
+
if (!info || !h)
|
|
230
|
+
process.stdout.write(`daemon: not running (hooks decide in-process, ~100 ms; run: yenop daemon start)\n`);
|
|
231
|
+
else
|
|
232
|
+
process.stdout.write(`daemon: pid ${h.pid} on 127.0.0.1:${info.port}, ${h.decisions} decisions, up ${Math.round(h.uptimeMs / 60000)} min${isCurrentBuild(h) ? "" : " (older build; restarts on next call)"}\n`);
|
|
233
|
+
const { serviceState } = await import("../daemon/service.js");
|
|
234
|
+
const svc = serviceState();
|
|
235
|
+
if (svc.state !== "not-installed")
|
|
236
|
+
process.stdout.write(`service: ${svc.state}\n`);
|
|
237
|
+
// Which agents on this machine are hooked, and where. The claim "every agent" is checked, not assumed.
|
|
238
|
+
const hooked = (path, marker) => {
|
|
239
|
+
try {
|
|
240
|
+
return existsSync(path) && readFileSync(path, "utf8").includes(marker);
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
const cover = (project, user, marker) => hooked(project, marker) ? "project" : hooked(user, marker) ? "user" : "no";
|
|
247
|
+
const claude = cover(join(process.cwd(), ".claude", "settings.local.json"), join(homedir(), ".claude", "settings.json"), "hook claude-code");
|
|
248
|
+
const cursor = cover(join(process.cwd(), ".cursor", "hooks.json"), join(homedir(), ".cursor", "hooks.json"), "hook cursor");
|
|
249
|
+
const codexPath = hooked(join(process.cwd(), ".codex", "hooks.json"), "hook codex") ? join(process.cwd(), ".codex", "hooks.json") : hooked(join(homedir(), ".codex", "hooks.json"), "hook codex") ? join(homedir(), ".codex", "hooks.json") : undefined;
|
|
250
|
+
const codex = codexPath === undefined ? "no" : codexPath.startsWith(homedir() + "/.codex") ? "user" : "project";
|
|
251
|
+
const gemini = cover(join(process.cwd(), ".gemini", "settings.json"), join(homedir(), ".gemini", "settings.json"), "hook gemini");
|
|
252
|
+
process.stdout.write(`agents: claude-code=${claude} cursor=${cursor} codex=${codex} gemini=${gemini} (yenop init hooks what it detects; --agents forces a list)\n`);
|
|
253
|
+
if (gemini === "project") {
|
|
254
|
+
// Gemini skips a project's hooks in a folder it does not trust, and warns about hooks nobody acknowledged.
|
|
255
|
+
const { geminiFolderTrust, geminiHookTrust, GEMINI_MARKER } = await import("../adapters/gemini/install.js");
|
|
256
|
+
if (geminiFolderTrust(process.cwd()) === "untrusted")
|
|
257
|
+
process.stdout.write(`WARNING: Gemini does not trust this folder, so it skips the project's hooks: nothing is enforced on Gemini here. In Gemini run /permissions and trust the folder.\n`);
|
|
258
|
+
try {
|
|
259
|
+
const cmd = JSON.parse(readFileSync(join(process.cwd(), ".gemini", "settings.json"), "utf8")).hooks?.["BeforeTool"]?.flatMap((g) => g.hooks ?? []).map((h) => h.command ?? "").find((c) => c.endsWith(GEMINI_MARKER));
|
|
260
|
+
if (cmd && geminiHookTrust(process.cwd(), cmd) === "untrusted")
|
|
261
|
+
process.stdout.write(`note: Gemini will show the Yenop hooks for review on its next start in this project; acknowledge them there.\n`);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
/* unreadable settings: the coverage line already says what is known */
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (codexPath !== undefined) {
|
|
268
|
+
// Codex keeps its own per-hook trust switch. Installed is not the same as running.
|
|
269
|
+
const { codexHookState } = await import("../adapters/codex/install.js");
|
|
270
|
+
const pre = codexHookState(codexPath, "PreToolUse");
|
|
271
|
+
if (pre === "disabled")
|
|
272
|
+
process.stdout.write(`WARNING: Codex has the Yenop PreToolUse hook DISABLED, so nothing is enforced on Codex. In Codex run /hooks and enable it.\n`);
|
|
273
|
+
else if (pre === "untrusted")
|
|
274
|
+
process.stdout.write(`WARNING: Codex has not trusted the Yenop hooks yet. Start Codex in this project and choose "Trust all"; until then nothing is enforced on Codex.\n`);
|
|
275
|
+
}
|
|
276
|
+
return 0;
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
y.close();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
case "schema": {
|
|
283
|
+
const { builtinPoliciesDir, loadSchema } = await import("../core/index.js");
|
|
284
|
+
process.stdout.write(loadSchema(builtinPoliciesDir()));
|
|
285
|
+
process.stdout.write(`\n// Example, in <project>/.yenop/policies/approve/team.cedar (a permit in approve/ means "ask a person"):\n//\n// @id("ask-before-npm-install")\n// permit (principal, action == Yenop::Action::"call", resource)\n// when { context has shell && (context.shell.ops.contains("npm:install") || context.shell.ops.contains("npm:i") || context.shell.ops.contains("npm:add") || context.shell.ops.contains("npm:ci")) };\n//\n// Check your work: yenop check Try it: yenop explain Bash '{"command":"npm install x"}'\n`);
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
case "init":
|
|
289
|
+
return init(rest);
|
|
290
|
+
case "viewer": {
|
|
291
|
+
const { values } = parseArgs({ args: rest, options: { port: { type: "string" }, all: { type: "boolean", default: false }, open: { type: "boolean", default: false } } });
|
|
292
|
+
const { openYenop } = await import("../core/index.js");
|
|
293
|
+
const { startViewer } = await import("../viewer/server.js");
|
|
294
|
+
const y = openYenop({ cwd: process.cwd(), dryRun: true });
|
|
295
|
+
const vopts = { all: values.all };
|
|
296
|
+
if (values.port)
|
|
297
|
+
vopts.port = Number(values.port);
|
|
298
|
+
const h = await startViewer(y.config, vopts);
|
|
299
|
+
process.stdout.write(`yenop receipts viewer at ${h.url}\n(${values.all ? "every project" : `tenant "${y.config.tenant.name}"`}; read-only; this machine only; Ctrl-C to stop)\n`);
|
|
300
|
+
if (values.open) {
|
|
301
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
302
|
+
try {
|
|
303
|
+
const { spawn } = await import("node:child_process");
|
|
304
|
+
spawn(opener, [h.url], { stdio: "ignore", detached: true }).unref();
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
/* the URL is printed; opening is a convenience */
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
const stop = () => void h.close().then(() => y.close()).then(() => process.exit(0));
|
|
311
|
+
process.on("SIGINT", stop);
|
|
312
|
+
process.on("SIGTERM", stop);
|
|
313
|
+
await new Promise(() => { });
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
case "report": {
|
|
317
|
+
const { values } = parseArgs({ args: rest, options: { days: { type: "string", default: "7" }, all: { type: "boolean", default: false }, share: { type: "boolean", default: false } } });
|
|
318
|
+
const { openYenop, buildReport, renderReport } = await import("../core/index.js");
|
|
319
|
+
const y = openYenop({ cwd: process.cwd(), dryRun: true });
|
|
320
|
+
try {
|
|
321
|
+
const r = buildReport(y.config, { days: Number(values.days), all: values.all });
|
|
322
|
+
process.stdout.write(renderReport(r) + "\n");
|
|
323
|
+
if (values.share)
|
|
324
|
+
return shareReport(y.config.home, r);
|
|
325
|
+
return 0;
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
y.close();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
case "telemetry":
|
|
332
|
+
return telemetryCommand(rest[0], rest.slice(1));
|
|
333
|
+
case "feedback": {
|
|
334
|
+
const url = "https://github.com/yenopdev/yenop/discussions";
|
|
335
|
+
process.stdout.write(`Tell us what you found: ${url}\nThe most useful things to share: an ask that should not have been asked, a deny that was wrong, or something an agent did that Yenop missed.\n`);
|
|
336
|
+
openInBrowser(url);
|
|
337
|
+
return 0;
|
|
338
|
+
}
|
|
339
|
+
case "demo": {
|
|
340
|
+
const { runDemo } = await import("./demo.js");
|
|
341
|
+
const opts = {};
|
|
342
|
+
if (rest.includes("--no-color"))
|
|
343
|
+
opts.color = false;
|
|
344
|
+
if (rest.includes("--color"))
|
|
345
|
+
opts.color = true;
|
|
346
|
+
return runDemo(opts);
|
|
347
|
+
}
|
|
348
|
+
case "playground": {
|
|
349
|
+
const dir = resolve(rest[0] ?? join(homedir(), "yenop-playground"));
|
|
350
|
+
const { hookCommandFor } = await import("../adapters/claude-code/install.js");
|
|
351
|
+
const { CONFIG_VERSION, newTenantId } = await import("../core/index.js");
|
|
352
|
+
await writePlayground(dir, hookCommandFor(fileURLToPath(import.meta.url)), CONFIG_VERSION, newTenantId());
|
|
353
|
+
process.stdout.write(`playground ready at ${dir} (enforce mode)\n`);
|
|
354
|
+
process.stdout.write(`open that folder in Claude Code and try:\n`);
|
|
355
|
+
process.stdout.write(` - "delete the build folder with rm -rf" -> Yenop asks\n`);
|
|
356
|
+
process.stdout.write(` - "run terraform destroy on infra/" -> Yenop asks\n`);
|
|
357
|
+
process.stdout.write(` - "print the database password from the env file" -> Yenop denies\n`);
|
|
358
|
+
process.stdout.write(` - "run npm test" -> nothing, normal flow\n`);
|
|
359
|
+
return 0;
|
|
360
|
+
}
|
|
361
|
+
case undefined:
|
|
362
|
+
case "help":
|
|
363
|
+
case "--help":
|
|
364
|
+
case "-h":
|
|
365
|
+
process.stdout.write(USAGE);
|
|
366
|
+
return 0;
|
|
367
|
+
default:
|
|
368
|
+
return fail(`unknown command: ${cmd}\n\n${USAGE}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
async function serviceCommand(sub) {
|
|
372
|
+
const { installService, uninstallService, serviceState, servicePlan } = await import("../daemon/service.js");
|
|
373
|
+
switch (sub) {
|
|
374
|
+
case "install": {
|
|
375
|
+
mkdirSync(home(), { recursive: true });
|
|
376
|
+
const r = installService(home());
|
|
377
|
+
process.stdout.write(`${r.message}\n`);
|
|
378
|
+
const { ensureDaemon } = await import("../daemon/client.js");
|
|
379
|
+
const info = await ensureDaemon(home(), { start: true, waitMs: 6000 });
|
|
380
|
+
process.stdout.write(info ? `daemon answering on 127.0.0.1:${info.port}\n` : `note: the daemon has not answered yet; check ${join(home(), "daemon.log")}\n`);
|
|
381
|
+
process.stdout.write(`the http hook is now safe to use as the default: yenop init --hook http\n`);
|
|
382
|
+
return 0;
|
|
383
|
+
}
|
|
384
|
+
case "uninstall":
|
|
385
|
+
process.stdout.write(`${uninstallService().message}\n`);
|
|
386
|
+
return 0;
|
|
387
|
+
case "show": {
|
|
388
|
+
process.stdout.write(servicePlan(home()).unit || "(no service on this platform)\n");
|
|
389
|
+
return 0;
|
|
390
|
+
}
|
|
391
|
+
case "status":
|
|
392
|
+
case undefined: {
|
|
393
|
+
const s = serviceState();
|
|
394
|
+
process.stdout.write(`service: ${s.state} (${s.detail})\n`);
|
|
395
|
+
return s.state === "running" ? 0 : 1;
|
|
396
|
+
}
|
|
397
|
+
default:
|
|
398
|
+
return fail(`unknown service command: ${sub}`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
async function daemonCommand(sub) {
|
|
402
|
+
const h = home();
|
|
403
|
+
switch (sub) {
|
|
404
|
+
case "run": {
|
|
405
|
+
// Foreground. `start` spawns this detached.
|
|
406
|
+
const { startDaemon } = await import("../daemon/server.js");
|
|
407
|
+
mkdirSync(h, { recursive: true });
|
|
408
|
+
const d = await startDaemon({ home: h });
|
|
409
|
+
const stop = () => void d.close().then(() => process.exit(0));
|
|
410
|
+
process.on("SIGINT", stop);
|
|
411
|
+
process.on("SIGTERM", stop);
|
|
412
|
+
process.stdout.write(`yenop daemon listening on 127.0.0.1:${d.info.port} (pid ${process.pid})\n`);
|
|
413
|
+
await new Promise(() => { });
|
|
414
|
+
return 0;
|
|
415
|
+
}
|
|
416
|
+
case "start": {
|
|
417
|
+
const { ensureDaemon } = await import("../daemon/client.js");
|
|
418
|
+
mkdirSync(h, { recursive: true });
|
|
419
|
+
const info = await ensureDaemon(h, { start: true, waitMs: 5000 });
|
|
420
|
+
if (!info)
|
|
421
|
+
return fail(`daemon did not come up; see ${join(h, "daemon.log")}`);
|
|
422
|
+
process.stdout.write(`daemon running: pid ${info.pid} on 127.0.0.1:${info.port}\n`);
|
|
423
|
+
return 0;
|
|
424
|
+
}
|
|
425
|
+
case "stop": {
|
|
426
|
+
const { readDaemonInfo, daemonRequest } = await import("../daemon/client.js");
|
|
427
|
+
const info = readDaemonInfo(h);
|
|
428
|
+
if (!info) {
|
|
429
|
+
process.stdout.write("daemon not running\n");
|
|
430
|
+
return 0;
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
await daemonRequest(info, "/shutdown", {});
|
|
434
|
+
process.stdout.write(`daemon pid ${info.pid} stopped\n`);
|
|
435
|
+
}
|
|
436
|
+
catch {
|
|
437
|
+
rmSync(join(h, "daemon.json"), { force: true });
|
|
438
|
+
process.stdout.write("daemon was not answering; cleared its record\n");
|
|
439
|
+
}
|
|
440
|
+
return 0;
|
|
441
|
+
}
|
|
442
|
+
case "status":
|
|
443
|
+
case undefined: {
|
|
444
|
+
const { readDaemonInfo, daemonHealthy, isCurrentBuild } = await import("../daemon/client.js");
|
|
445
|
+
const info = readDaemonInfo(h);
|
|
446
|
+
const st = info ? await daemonHealthy(info) : undefined;
|
|
447
|
+
if (!info || !st) {
|
|
448
|
+
process.stdout.write("daemon: not running\n");
|
|
449
|
+
return 1;
|
|
450
|
+
}
|
|
451
|
+
process.stdout.write(`daemon: pid ${st.pid} on 127.0.0.1:${info.port}, ${st.decisions} decisions, ${st.instances} projects, up ${Math.round(st.uptimeMs / 1000)} s, build ${isCurrentBuild(st) ? "current" : "stale"}\n`);
|
|
452
|
+
return 0;
|
|
453
|
+
}
|
|
454
|
+
default:
|
|
455
|
+
return fail(`unknown daemon command: ${sub}`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
async function init(rest) {
|
|
459
|
+
const { values } = parseArgs({
|
|
460
|
+
args: rest,
|
|
461
|
+
options: {
|
|
462
|
+
user: { type: "boolean", default: false },
|
|
463
|
+
"no-hook": { type: "boolean", default: false },
|
|
464
|
+
mode: { type: "string", default: "enforce" },
|
|
465
|
+
hook: { type: "string", default: "command" },
|
|
466
|
+
agents: { type: "string" },
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
if (values.mode !== "enforce" && values.mode !== "observe")
|
|
470
|
+
return fail(`--mode must be enforce or observe`);
|
|
471
|
+
if (values.hook !== "command" && values.hook !== "http")
|
|
472
|
+
return fail(`--hook must be command or http`);
|
|
473
|
+
const { builtinPoliciesDir, newTenantId, CONFIG_VERSION } = await import("../core/index.js");
|
|
474
|
+
const { installClaudeCodeHook, installClaudeCodeHttpHook, hookCommandFor } = await import("../adapters/claude-code/install.js");
|
|
475
|
+
const { ensureDaemon } = await import("../daemon/client.js");
|
|
476
|
+
const h = home();
|
|
477
|
+
mkdirSync(h, { recursive: true });
|
|
478
|
+
const policies = join(h, "policies");
|
|
479
|
+
migrateCopiedBaseline(policies, builtinPoliciesDir());
|
|
480
|
+
for (const which of ["permit", "approve"]) {
|
|
481
|
+
const dir = join(policies, which);
|
|
482
|
+
mkdirSync(dir, { recursive: true });
|
|
483
|
+
const local = join(dir, "local.cedar");
|
|
484
|
+
if (!existsSync(local))
|
|
485
|
+
writeFileSync(local, LOCAL_TEMPLATE[which]);
|
|
486
|
+
}
|
|
487
|
+
writeFileSync(join(policies, "README.md"), readFileSync(join(builtinPoliciesDir(), "README.md"), "utf8"));
|
|
488
|
+
process.stdout.write(`policies: baseline comes from the package; your own rules go in ${policies}/{permit,approve}/\n`);
|
|
489
|
+
const cfg = join(h, "config.json");
|
|
490
|
+
if (!existsSync(cfg)) {
|
|
491
|
+
writeFileSync(cfg, JSON.stringify({ v: CONFIG_VERSION, tenant: { id: newTenantId(), name: "local" }, mode: values.mode, budgets: { maxStepsPerRun: 1000, maxDeniesPerRun: 20 }, disabledPolicies: [], secretPatterns: [] }, null, 2) + "\n");
|
|
492
|
+
process.stdout.write(`created ${cfg}\n`);
|
|
493
|
+
}
|
|
494
|
+
else {
|
|
495
|
+
upgradeConfig(cfg, CONFIG_VERSION, newTenantId);
|
|
496
|
+
// The machine's config already exists, so --mode would be silently ignored. A mode asked for explicitly
|
|
497
|
+
// is set for this project instead (its own layer), which is what a person running `init --mode observe`
|
|
498
|
+
// in a second project means.
|
|
499
|
+
if (rest.includes("--mode")) {
|
|
500
|
+
const projectCfg = join(process.cwd(), ".yenop", "config.json");
|
|
501
|
+
const current = existsSync(projectCfg) ? JSON.parse(readFileSync(projectCfg, "utf8")) : {};
|
|
502
|
+
if (current["mode"] !== values.mode) {
|
|
503
|
+
mkdirSync(dirname(projectCfg), { recursive: true });
|
|
504
|
+
writeFileSync(projectCfg, JSON.stringify({ ...current, mode: values.mode }, null, 2) + "\n");
|
|
505
|
+
process.stdout.write(`mode ${values.mode} set for this project in ${projectCfg}\n`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
const daemon = await ensureDaemon(h, { start: true, waitMs: 5000 });
|
|
510
|
+
process.stdout.write(daemon ? `daemon: running on 127.0.0.1:${daemon.port}\n` : `daemon: could not start; hooks will decide in-process (see ${join(h, "daemon.log")})\n`);
|
|
511
|
+
if (!values["no-hook"]) {
|
|
512
|
+
const settingsPath = values.user ? join(homedir(), ".claude", "settings.json") : join(process.cwd(), ".claude", "settings.local.json");
|
|
513
|
+
if (values.hook === "http") {
|
|
514
|
+
if (!daemon)
|
|
515
|
+
return fail("the http hook needs the daemon; start it with: yenop daemon start");
|
|
516
|
+
const { serviceState } = await import("../daemon/service.js");
|
|
517
|
+
const svc = serviceState();
|
|
518
|
+
if (svc.state !== "running") {
|
|
519
|
+
process.stdout.write(`warning: the http hook fails open if the daemon is down, and nothing is supervising it.\n run "yenop service install" first, or use the default command hook.\n`);
|
|
520
|
+
}
|
|
521
|
+
const r = installClaudeCodeHttpHook(settingsPath, daemon.port, daemon.token);
|
|
522
|
+
process.stdout.write(`${r.changed ? "installed" : "already installed"} Claude Code http hook in ${r.path}\n`);
|
|
523
|
+
}
|
|
524
|
+
else {
|
|
525
|
+
const r = installClaudeCodeHook(settingsPath, hookCommandFor(fileURLToPath(import.meta.url)));
|
|
526
|
+
process.stdout.write(`${r.changed ? "installed" : "already installed"} Claude Code hook in ${r.path}\n`);
|
|
527
|
+
}
|
|
528
|
+
// Every other agent on this machine gets the same guard. Detected automatically; --agents forces a list.
|
|
529
|
+
const cliPath = fileURLToPath(import.meta.url);
|
|
530
|
+
const wanted = values.agents ? values.agents.split(",").map((s) => s.trim()).filter(Boolean) : undefined;
|
|
531
|
+
const { installCursorHooks, cursorHooksPath, cursorDetected } = await import("../adapters/cursor/install.js");
|
|
532
|
+
if (wanted ? wanted.includes("cursor") : cursorDetected(process.cwd())) {
|
|
533
|
+
const r = installCursorHooks(cursorHooksPath(values.user ? "user" : "project", process.cwd()), hookCommandFor(cliPath, "cursor"));
|
|
534
|
+
process.stdout.write(`${r.changed ? "installed" : "already installed"} Cursor hooks in ${r.path}\n`);
|
|
535
|
+
}
|
|
536
|
+
const { installCodexHooks, codexHooksPath, codexDetected } = await import("../adapters/codex/install.js");
|
|
537
|
+
if (wanted ? wanted.includes("codex") : codexDetected(process.cwd())) {
|
|
538
|
+
const r = installCodexHooks(codexHooksPath(values.user ? "user" : "project", process.cwd()), hookCommandFor(cliPath, "codex"));
|
|
539
|
+
process.stdout.write(`${r.changed ? "installed" : "already installed"} Codex hooks in ${r.path}\n`);
|
|
540
|
+
process.stdout.write(` Codex will ask you to review new hooks on its next start: choose "Trust all". A hook Codex has not trusted, or has disabled, never runs; "yenop status" checks.\n`);
|
|
541
|
+
}
|
|
542
|
+
const { installGeminiHooks, geminiSettingsPath, geminiDetected } = await import("../adapters/gemini/install.js");
|
|
543
|
+
if (wanted ? wanted.includes("gemini") : geminiDetected(process.cwd())) {
|
|
544
|
+
const r = installGeminiHooks(geminiSettingsPath(values.user ? "user" : "project", process.cwd()), hookCommandFor(cliPath, "gemini"));
|
|
545
|
+
process.stdout.write(`${r.changed ? "installed" : "already installed"} Gemini CLI hooks in ${r.path}\n`);
|
|
546
|
+
if (!values.user)
|
|
547
|
+
process.stdout.write(` Gemini shows a project's new hooks for review on its next start; acknowledge them. It skips project hooks in a folder it does not trust; "yenop status" checks.\n`);
|
|
548
|
+
}
|
|
549
|
+
const { hookRuntimes } = await import("../adapters/hooks/registry.js");
|
|
550
|
+
process.stdout.write(`agents Yenop can hook: ${hookRuntimes().join(", ")}\n`);
|
|
551
|
+
}
|
|
552
|
+
// Close with what a person needs to know: which mode is in force here, and what to do next.
|
|
553
|
+
const { openYenop } = await import("../core/index.js");
|
|
554
|
+
const effective = openYenop({ cwd: process.cwd(), dryRun: true });
|
|
555
|
+
const mode = effective.config.mode;
|
|
556
|
+
effective.close();
|
|
557
|
+
process.stdout.write(mode === "observe"
|
|
558
|
+
? `mode: observe: every decision is recorded, nothing is blocked. Switch with: yenop init --mode enforce\n`
|
|
559
|
+
: `mode: enforce: decisions are returned to the agent. To only record for a week first: yenop init --mode observe\n`);
|
|
560
|
+
process.stdout.write(`\nnext: yenop demo a scripted attack, judged live\n yenop viewer --open watch the receipts while an agent works\n yenop report after a week: what would have been stopped, and were the asks right\n`);
|
|
561
|
+
return 0;
|
|
562
|
+
}
|
|
563
|
+
function openInBrowser(url) {
|
|
564
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
565
|
+
import("node:child_process").then(({ spawn }) => {
|
|
566
|
+
try {
|
|
567
|
+
spawn(opener, [url], { stdio: "ignore", detached: true }).unref();
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
/* the URL is printed */
|
|
571
|
+
}
|
|
572
|
+
}).catch(() => { });
|
|
573
|
+
}
|
|
574
|
+
async function shareReport(h, r) {
|
|
575
|
+
const { readTelemetry, toTelemetry, sendTelemetry, hookedRuntimes, DEFAULT_TELEMETRY_ENDPOINT } = await import("../core/index.js");
|
|
576
|
+
const t = readTelemetry(h);
|
|
577
|
+
if (!t.enabled || !t.installId) {
|
|
578
|
+
process.stdout.write(`\nNot sent: telemetry is off. Turn it on with "yenop telemetry enable" (aggregate numbers only; "yenop telemetry status" shows exactly what would be sent).\n`);
|
|
579
|
+
return 1;
|
|
580
|
+
}
|
|
581
|
+
const payload = toTelemetry(r, t.installId, PKG_VERSION, hookedRuntimes(process.cwd()));
|
|
582
|
+
const ok = await sendTelemetry(t.endpoint ?? DEFAULT_TELEMETRY_ENDPOINT, payload);
|
|
583
|
+
process.stdout.write(ok ? `\nSent the numbers above (no commands, paths, prompts or names). Thank you.\n` : `\nCould not reach the telemetry endpoint; nothing was sent. Try again later or share the report by hand at https://github.com/yenopdev/yenop/discussions\n`);
|
|
584
|
+
return ok ? 0 : 1;
|
|
585
|
+
}
|
|
586
|
+
/** Which runtimes have a Yenop hook registered for the current project or user. Names only. */
|
|
587
|
+
async function telemetryCommand(sub, args = []) {
|
|
588
|
+
const { readTelemetry, enableTelemetry, disableTelemetry, resetInstallId, toTelemetry, buildReport, openYenop, hookedRuntimes, DEFAULT_TELEMETRY_ENDPOINT } = await import("../core/index.js");
|
|
589
|
+
const h = home();
|
|
590
|
+
switch (sub) {
|
|
591
|
+
case "enable": {
|
|
592
|
+
// A self-hosted receiver (an on-prem control plane) may be named. Off loopback it must be HTTPS: the
|
|
593
|
+
// payload is aggregate, but it still leaves the machine.
|
|
594
|
+
let endpoint;
|
|
595
|
+
const i = args.indexOf("--endpoint");
|
|
596
|
+
if (i !== -1) {
|
|
597
|
+
endpoint = args[i + 1];
|
|
598
|
+
let u;
|
|
599
|
+
try {
|
|
600
|
+
u = endpoint ? new URL(endpoint) : undefined;
|
|
601
|
+
}
|
|
602
|
+
catch { /* reported below */ }
|
|
603
|
+
const loopback = u && (u.hostname === "127.0.0.1" || u.hostname === "localhost" || u.hostname === "[::1]");
|
|
604
|
+
if (!u || !(u.protocol === "https:" || (u.protocol === "http:" && loopback)))
|
|
605
|
+
return fail(`--endpoint must be an https:// URL (http:// only on loopback): ${endpoint ?? "(missing)"}`);
|
|
606
|
+
}
|
|
607
|
+
const s = enableTelemetry(h, endpoint);
|
|
608
|
+
process.stdout.write(`telemetry: on (install id ${s.installId}${s.endpoint ? `, endpoint ${s.endpoint}` : ""}). Aggregate counts only, sent at most once a day by the daemon or when you run "yenop report --share".\nWhat is sent: version, OS, and counts of actions by verdict, rule id, runtime and tool kind. Never a command, path, prompt, source file, hostname, user or project name.\n"yenop telemetry status" shows the exact payload; "yenop telemetry disable" turns it off.\n`);
|
|
609
|
+
return 0;
|
|
610
|
+
}
|
|
611
|
+
case "disable":
|
|
612
|
+
disableTelemetry(h);
|
|
613
|
+
process.stdout.write(`telemetry: off. Nothing will be sent.\n`);
|
|
614
|
+
return 0;
|
|
615
|
+
case "reset": {
|
|
616
|
+
const s = resetInstallId(h);
|
|
617
|
+
process.stdout.write(`telemetry: new install id ${s.installId}${s.enabled ? "" : " (telemetry is off)"}\n`);
|
|
618
|
+
return 0;
|
|
619
|
+
}
|
|
620
|
+
case "status":
|
|
621
|
+
case undefined: {
|
|
622
|
+
const s = readTelemetry(h);
|
|
623
|
+
process.stdout.write(`telemetry: ${s.enabled ? "on" : "off"}${s.installId ? ` install id ${s.installId}` : ""}${s.endpoint ? ` endpoint ${s.endpoint}` : ` endpoint ${DEFAULT_TELEMETRY_ENDPOINT}`}${s.lastSentAt ? ` last sent ${s.lastSentAt}` : ""}\n`);
|
|
624
|
+
const y = openYenop({ cwd: process.cwd(), dryRun: true });
|
|
625
|
+
try {
|
|
626
|
+
const payload = toTelemetry(buildReport(y.config, { days: 7 }), s.installId ?? "inst_(assigned on enable)", PKG_VERSION, hookedRuntimes(process.cwd()));
|
|
627
|
+
process.stdout.write(`\nExactly what would be sent (last 7 days):\n${JSON.stringify(payload, null, 2)}\n`);
|
|
628
|
+
}
|
|
629
|
+
finally {
|
|
630
|
+
y.close();
|
|
631
|
+
}
|
|
632
|
+
return 0;
|
|
633
|
+
}
|
|
634
|
+
default:
|
|
635
|
+
return fail(`unknown telemetry command: ${sub}`);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
const LOCAL_TEMPLATE = {
|
|
639
|
+
permit: `// Your own "may this happen at all" rules. The baseline pack is loaded automatically underneath.
|
|
640
|
+
// A forbid here wins over every permit. Give each policy its own @id so receipts can name it.
|
|
641
|
+
//
|
|
642
|
+
// @id("no-prod-db-from-agents")
|
|
643
|
+
// forbid (principal, action, resource)
|
|
644
|
+
// when { context has shell && context.shell.paths.contains("/srv/prod-db") };
|
|
645
|
+
`,
|
|
646
|
+
approve: `// Your own "must a person see this first" rules. A permit here means: allowed, but ask.
|
|
647
|
+
//
|
|
648
|
+
// @id("payments-api")
|
|
649
|
+
// permit (principal, action == Yenop::Action::"call", resource)
|
|
650
|
+
// when { context has shell && context.shell.ops.contains("curl:-X") && context.args has command && context.args.command like "*api.stripe.com*" };
|
|
651
|
+
`,
|
|
652
|
+
};
|
|
653
|
+
/** Bring an older config.json up to the current shape without losing anything the user set. */
|
|
654
|
+
function upgradeConfig(path, version, newTenantId) {
|
|
655
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
656
|
+
let changed = false;
|
|
657
|
+
if (raw["v"] === undefined) {
|
|
658
|
+
raw["v"] = version;
|
|
659
|
+
changed = true;
|
|
660
|
+
}
|
|
661
|
+
if (typeof raw["tenant"] === "string" || raw["tenant"] === undefined) {
|
|
662
|
+
raw["tenant"] = { id: newTenantId(), name: raw["tenant"] ?? "local" };
|
|
663
|
+
changed = true;
|
|
664
|
+
}
|
|
665
|
+
if (changed) {
|
|
666
|
+
writeFileSync(path, JSON.stringify(raw, null, 2) + "\n");
|
|
667
|
+
process.stdout.write(`upgraded ${path} to config version ${version} (tenant now has a stable id)\n`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/** Comments and whitespace do not make a policy file different. */
|
|
671
|
+
function policyText(src) {
|
|
672
|
+
return src.split("\n").filter((l) => !l.trim().startsWith("//")).join(" ").replace(/\s+/g, " ").trim();
|
|
673
|
+
}
|
|
674
|
+
/** Installs before 0.0.2 copied the shipped pack into the home layer. Remove untouched copies so ids do not collide. */
|
|
675
|
+
function migrateCopiedBaseline(policiesDir, baselineDir) {
|
|
676
|
+
for (const which of ["permit", "approve"]) {
|
|
677
|
+
const copy = join(policiesDir, which, "default.cedar");
|
|
678
|
+
if (!existsSync(copy))
|
|
679
|
+
continue;
|
|
680
|
+
const shipped = join(baselineDir, which, "default.cedar");
|
|
681
|
+
if (existsSync(shipped) && policyText(readFileSync(copy, "utf8")) === policyText(readFileSync(shipped, "utf8"))) {
|
|
682
|
+
rmSync(copy);
|
|
683
|
+
process.stdout.write(`migrated: removed ${copy} (identical to the shipped baseline, which is now loaded from the package)\n`);
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
process.stdout.write(`note: ${copy} differs from the shipped baseline; rename its @ids or use disabledPolicies to avoid collisions\n`);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
async function writePlayground(dir, hookCommand, configVersion, tenantId) {
|
|
691
|
+
const w = (rel, content) => {
|
|
692
|
+
const p = join(dir, rel);
|
|
693
|
+
mkdirSync(join(p, ".."), { recursive: true });
|
|
694
|
+
writeFileSync(p, content);
|
|
695
|
+
};
|
|
696
|
+
w("README.md", "# Yenop playground\n\nA fake project for testing Yenop under enforcement. Nothing here is real. Delete the folder when done.\n");
|
|
697
|
+
// A tenant id never changes once issued. Refreshing the playground must keep the id it already has,
|
|
698
|
+
// or its earlier receipts stop matching.
|
|
699
|
+
let tenant = { id: tenantId, name: "playground" };
|
|
700
|
+
const cfgPath = join(dir, ".yenop", "config.json");
|
|
701
|
+
if (existsSync(cfgPath)) {
|
|
702
|
+
try {
|
|
703
|
+
const prev = JSON.parse(readFileSync(cfgPath, "utf8"));
|
|
704
|
+
if (prev.tenant?.id)
|
|
705
|
+
tenant = { id: prev.tenant.id, name: prev.tenant.name ?? "playground" };
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
/* unreadable config: issue a fresh id */
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
w(".yenop/config.json", JSON.stringify({ v: configVersion, tenant, mode: "enforce" }, null, 2) + "\n");
|
|
712
|
+
w("package.json", JSON.stringify({ name: "playground", private: true, scripts: { test: "node -e \"console.log('tests: 3 passed')\"", build: "mkdir -p build && echo built > build/out.txt" } }, null, 2) + "\n");
|
|
713
|
+
w("build/out.txt", "built\n");
|
|
714
|
+
w("infra/main.tf", 'resource "aws_db_instance" "prod" {\n identifier = "prod-db"\n allocated_storage = 100\n}\n');
|
|
715
|
+
w(".env", "DATABASE_URL=postgres://app:not-a-real-password@db.internal:5432/prod\nSTRIPE_KEY=sk_test_not_real\n");
|
|
716
|
+
w("src/app.js", "console.log('hello from the playground');\n");
|
|
717
|
+
w(".yenop/policies/README.md", "# Policies for this project\n\nRun `yenop schema` for the vocabulary and an example, `yenop check` to validate, `yenop explain` to try a call.\nA permit in `approve/` means ask a person. A forbid in `permit/` means never.\nAn invalid policy file makes Yenop refuse every action in this project until it is fixed.\n");
|
|
718
|
+
// loaded lazily so other commands do not pay for the installer; awaited, or the process exits before the file is written
|
|
719
|
+
const { installClaudeCodeHook } = await import("../adapters/claude-code/install.js");
|
|
720
|
+
installClaudeCodeHook(join(dir, ".claude", "settings.local.json"), hookCommand);
|
|
721
|
+
}
|
|
722
|
+
function fail(msg) {
|
|
723
|
+
process.stderr.write(`yenop: ${msg}\n`);
|
|
724
|
+
return 1;
|
|
725
|
+
}
|
|
726
|
+
function pad(s, n) {
|
|
727
|
+
return s.length >= n ? s : s + " ".repeat(n - s.length);
|
|
728
|
+
}
|
|
729
|
+
function summarizeArgs(args) {
|
|
730
|
+
if (!args || typeof args !== "object")
|
|
731
|
+
return "";
|
|
732
|
+
const a = args;
|
|
733
|
+
const key = ["command", "file_path", "path", "url", "query", "pattern"].find((k) => typeof a[k] === "string");
|
|
734
|
+
const v = (key ? String(a[key]) : JSON.stringify(a)).replace(/\s+/g, " ").trim();
|
|
735
|
+
return v.length > 80 ? v.slice(0, 77) + "..." : v;
|
|
736
|
+
}
|
|
737
|
+
main(process.argv.slice(2)).then((code) => {
|
|
738
|
+
const longRunning = (process.argv[2] === "daemon" && process.argv[3] === "run") || process.argv[2] === "mcp" || process.argv[2] === "viewer";
|
|
739
|
+
if (!longRunning)
|
|
740
|
+
process.exit(code);
|
|
741
|
+
process.exitCode = code;
|
|
742
|
+
}, (err) => {
|
|
743
|
+
process.stderr.write(`yenop: ${err.message}\n`);
|
|
744
|
+
process.exit(1);
|
|
745
|
+
});
|
|
746
|
+
//# sourceMappingURL=main.js.map
|