@memnox/interceptors 0.1.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/LICENSE +201 -0
- package/README.md +54 -0
- package/dist/chunk-SB27OD2T.js +206 -0
- package/dist/egress-cli.js +275 -0
- package/dist/git-credential-cli.js +74 -0
- package/dist/index.d.ts +521 -0
- package/dist/index.js +1123 -0
- package/dist/interceptor-cli.js +530 -0
- package/dist/shell-cli.js +258 -0
- package/package.json +54 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1123 @@
|
|
|
1
|
+
// src/tool-hook.constants.ts
|
|
2
|
+
var ENV_POLICIES = "MEMNOX_POLICIES";
|
|
3
|
+
var ENV_AGENT_NAME = "MEMNOX_AGENT_NAME";
|
|
4
|
+
var ENV_AGENT_ROLE = "MEMNOX_AGENT_ROLE";
|
|
5
|
+
var POLICY_PATH_SEPARATOR = ",";
|
|
6
|
+
var DEFAULT_AGENT_NAME = "claude-code";
|
|
7
|
+
var EGRESS_ACTIONS = ["http.request", "data.export"];
|
|
8
|
+
var EGRESS_DEFAULT_PORT = 8888;
|
|
9
|
+
var EGRESS_MAX_BODY_BYTES = 1e6;
|
|
10
|
+
|
|
11
|
+
// src/hook-authorizer.ts
|
|
12
|
+
import { DECISION_EFFECT, describeEgress, inspectEgress } from "@memnox/core";
|
|
13
|
+
var HookAuthorizer = class {
|
|
14
|
+
constructor(deps) {
|
|
15
|
+
this.deps = deps;
|
|
16
|
+
}
|
|
17
|
+
deps;
|
|
18
|
+
async authorize(request) {
|
|
19
|
+
const leaking = this.egress(request);
|
|
20
|
+
if (leaking !== null) return leaking;
|
|
21
|
+
const local = this.locally(request);
|
|
22
|
+
if (local !== null && local.effect === DECISION_EFFECT.DENY) {
|
|
23
|
+
return local;
|
|
24
|
+
}
|
|
25
|
+
return local ?? { effect: DECISION_EFFECT.ALLOW, reason: "no rules configured" };
|
|
26
|
+
}
|
|
27
|
+
// Nothing is modified: silently stripping a payload is a bug nobody can audit.
|
|
28
|
+
egress(request) {
|
|
29
|
+
if (!EGRESS_ACTIONS.includes(request.action)) return null;
|
|
30
|
+
const fields = request.arguments;
|
|
31
|
+
if (fields === void 0) return null;
|
|
32
|
+
const inspection = inspectEgress({
|
|
33
|
+
...request.target === void 0 ? {} : { destination: request.target },
|
|
34
|
+
fields
|
|
35
|
+
});
|
|
36
|
+
if (inspection.findings.length === 0) return null;
|
|
37
|
+
return {
|
|
38
|
+
effect: DECISION_EFFECT.DENY,
|
|
39
|
+
reason: describeEgress(inspection),
|
|
40
|
+
alternative: {
|
|
41
|
+
action: request.action,
|
|
42
|
+
note: "send the request without that field, or reference the value by name"
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Null when no policy files were configured, which leaves the runtime as the gate. */
|
|
47
|
+
locally(request) {
|
|
48
|
+
const gate = this.deps.gate;
|
|
49
|
+
if (gate === void 0) return null;
|
|
50
|
+
const verdict = gate.evaluate(request);
|
|
51
|
+
const rule = verdict.matchedPolicies[0];
|
|
52
|
+
return {
|
|
53
|
+
effect: verdict.effect,
|
|
54
|
+
reason: verdict.reason,
|
|
55
|
+
...rule === void 0 ? {} : { rule: rule.name },
|
|
56
|
+
...verdict.alternative === void 0 ? {} : { alternative: verdict.alternative }
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// src/hook-config.ts
|
|
62
|
+
import { join } from "path";
|
|
63
|
+
import { readPolicyRegistry } from "@memnox/core";
|
|
64
|
+
var CONFIG_DIR = ".memnox";
|
|
65
|
+
var REGISTRY_FILE = "policies.json";
|
|
66
|
+
async function readHookConfig(env, homeDir) {
|
|
67
|
+
const configured = env[ENV_POLICIES];
|
|
68
|
+
return {
|
|
69
|
+
policyFiles: configured === void 0 || configured.trim().length === 0 ? await readPolicyRegistry(join(homeDir, CONFIG_DIR, REGISTRY_FILE)) : splitPaths(configured),
|
|
70
|
+
...pick("agentName", env[ENV_AGENT_NAME]),
|
|
71
|
+
...pick("agentRole", env[ENV_AGENT_ROLE])
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function splitPaths(value) {
|
|
75
|
+
return value.split(POLICY_PATH_SEPARATOR).map((path) => path.trim()).filter((path) => path.length > 0);
|
|
76
|
+
}
|
|
77
|
+
function pick(key, value) {
|
|
78
|
+
if (value === void 0 || value.length === 0) return {};
|
|
79
|
+
return { [key]: value };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/hook-gate-loader.ts
|
|
83
|
+
import { homedir } from "os";
|
|
84
|
+
import {
|
|
85
|
+
LocalGate,
|
|
86
|
+
SESSION_VAR,
|
|
87
|
+
SessionTasks,
|
|
88
|
+
overlaysInForce,
|
|
89
|
+
stateFactsInForce
|
|
90
|
+
} from "@memnox/core";
|
|
91
|
+
async function loadHookGate(config, home = homedir(), now = () => (/* @__PURE__ */ new Date()).toISOString()) {
|
|
92
|
+
if (config.policyFiles.length === 0) return null;
|
|
93
|
+
const overlays = await overlaysInForce(home);
|
|
94
|
+
const sessionId = process.env[SESSION_VAR];
|
|
95
|
+
const task = sessionId === void 0 ? null : await new SessionTasks(home).read(sessionId);
|
|
96
|
+
return LocalGate.fromFiles(config.policyFiles, {
|
|
97
|
+
agentName: config.agentName ?? DEFAULT_AGENT_NAME,
|
|
98
|
+
...config.agentRole === void 0 ? {} : { agentRole: config.agentRole },
|
|
99
|
+
task,
|
|
100
|
+
stateFacts: stateFactsInForce(overlays, now())
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/shell-seam.ts
|
|
105
|
+
import {
|
|
106
|
+
DECISION_EFFECT as DECISION_EFFECT2,
|
|
107
|
+
digest,
|
|
108
|
+
isAllowed as holdAllowed,
|
|
109
|
+
leasePathFor,
|
|
110
|
+
leaseScopeFor,
|
|
111
|
+
proceeds,
|
|
112
|
+
resolveShellLine,
|
|
113
|
+
takesLease
|
|
114
|
+
} from "@memnox/core";
|
|
115
|
+
var SHELL_ACTION = "shell.execute";
|
|
116
|
+
var SHELL_EXIT_OK = 0;
|
|
117
|
+
var SHELL_EXIT_WITHHELD = 77;
|
|
118
|
+
var SEVERITY = {
|
|
119
|
+
[DECISION_EFFECT2.ALLOW]: 0,
|
|
120
|
+
[DECISION_EFFECT2.ASK]: 1,
|
|
121
|
+
[DECISION_EFFECT2.DENY]: 2
|
|
122
|
+
};
|
|
123
|
+
function worse(a, b) {
|
|
124
|
+
return (SEVERITY[b.effect] ?? 0) > (SEVERITY[a.effect] ?? 0) ? b : a;
|
|
125
|
+
}
|
|
126
|
+
var ShellSeam = class {
|
|
127
|
+
constructor(deps) {
|
|
128
|
+
this.deps = deps;
|
|
129
|
+
}
|
|
130
|
+
deps;
|
|
131
|
+
async gate(command) {
|
|
132
|
+
if (command.length === 0) {
|
|
133
|
+
return { message: "no command to run", exitCode: SHELL_EXIT_WITHHELD };
|
|
134
|
+
}
|
|
135
|
+
const line = command.join(" ");
|
|
136
|
+
let verdict = await this.deps.authorizer.authorize(
|
|
137
|
+
this.requestFor(SHELL_ACTION, line)
|
|
138
|
+
);
|
|
139
|
+
for (const resolved of resolveShellLine(line, this.deps.env ?? {}).actions) {
|
|
140
|
+
if (resolved.action === SHELL_ACTION) continue;
|
|
141
|
+
const request = this.requestFor(resolved.action, resolved.target ?? line);
|
|
142
|
+
verdict = worse(verdict, await this.deps.authorizer.authorize(request));
|
|
143
|
+
}
|
|
144
|
+
if (verdict.effect === DECISION_EFFECT2.ASK) {
|
|
145
|
+
const answered = await this.askAbout(line, verdict);
|
|
146
|
+
if (answered !== null) return answered;
|
|
147
|
+
} else if (verdict.effect !== DECISION_EFFECT2.ALLOW) {
|
|
148
|
+
return { message: describe(verdict), exitCode: SHELL_EXIT_WITHHELD };
|
|
149
|
+
}
|
|
150
|
+
const held = await this.claim(line);
|
|
151
|
+
if (held !== null) return held;
|
|
152
|
+
return { run: command, exitCode: SHELL_EXIT_OK };
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Puts an ASK to a person. Null when it was allowed and the line may proceed.
|
|
156
|
+
*
|
|
157
|
+
* Withheld rather than run when nobody answers: a walk-away must not become a yes,
|
|
158
|
+
* and a timeout is said differently from a refusal so the two read differently.
|
|
159
|
+
*/
|
|
160
|
+
async askAbout(line, verdict) {
|
|
161
|
+
const hold = this.deps.hold;
|
|
162
|
+
if (hold === void 0) {
|
|
163
|
+
return {
|
|
164
|
+
message: `${describe(verdict)}
|
|
165
|
+
Nobody could be asked, so it was withheld. Run the agent under "memnox run".`,
|
|
166
|
+
exitCode: SHELL_EXIT_WITHHELD
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const result = await hold.hold({
|
|
170
|
+
sessionId: this.deps.sessionId ?? "ses_local",
|
|
171
|
+
agent: "an agent",
|
|
172
|
+
operation: SHELL_ACTION,
|
|
173
|
+
fingerprint: digest(line),
|
|
174
|
+
reason: verdict.reason,
|
|
175
|
+
command: line
|
|
176
|
+
});
|
|
177
|
+
if (holdAllowed(result)) return null;
|
|
178
|
+
return { message: describe(verdict), exitCode: SHELL_EXIT_WITHHELD };
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Takes the paths this line writes, or answers with who is already on them. Reads
|
|
182
|
+
* never reach the register: `takesLease` decides that here, so there is no path
|
|
183
|
+
* through this seam that can make a reader wait.
|
|
184
|
+
*/
|
|
185
|
+
async claim(line) {
|
|
186
|
+
const leases = this.deps.leases;
|
|
187
|
+
if (leases === void 0) return null;
|
|
188
|
+
for (const resolved of resolveShellLine(line, this.deps.env ?? {}).actions) {
|
|
189
|
+
if (!takesLease(String(resolved.class))) continue;
|
|
190
|
+
const path = leasePathFor(
|
|
191
|
+
resolved.target,
|
|
192
|
+
leases.repositoryRoot,
|
|
193
|
+
this.deps.workingDirectory ?? leases.repositoryRoot
|
|
194
|
+
);
|
|
195
|
+
if (path === null) continue;
|
|
196
|
+
const scope = leaseScopeFor(path, leases.isDirectory);
|
|
197
|
+
const verdict = await leases.gate.claim(
|
|
198
|
+
scope,
|
|
199
|
+
leases.holder,
|
|
200
|
+
`${resolved.action} ${resolved.target ?? ""}`.trim()
|
|
201
|
+
);
|
|
202
|
+
if (proceeds(verdict)) continue;
|
|
203
|
+
return {
|
|
204
|
+
message: verdict.message ?? `${scope} is held by another agent.`,
|
|
205
|
+
exitCode: SHELL_EXIT_WITHHELD
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
requestFor(action, target) {
|
|
211
|
+
return {
|
|
212
|
+
action,
|
|
213
|
+
target,
|
|
214
|
+
// LOCAL ONLY. The SDK strips this before anything reaches the runtime.
|
|
215
|
+
arguments: { command: target },
|
|
216
|
+
...this.deps.sessionId === void 0 ? {} : { sessionId: this.deps.sessionId },
|
|
217
|
+
...this.deps.workingDirectory === void 0 ? {} : { workingDirectory: this.deps.workingDirectory }
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
function describe(verdict) {
|
|
222
|
+
const parts = [verdict.reason];
|
|
223
|
+
if (verdict.alternative !== void 0) {
|
|
224
|
+
const target = verdict.alternative.resource === void 0 ? "" : ` ${verdict.alternative.resource}`;
|
|
225
|
+
parts.push(
|
|
226
|
+
`Instead: ${verdict.alternative.action}${target} \u2014 ${verdict.alternative.note}`
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
if (verdict.approvalId !== void 0) {
|
|
230
|
+
parts.push(`Ask a person: memnox approvals resolve ${verdict.approvalId} --by <you>`);
|
|
231
|
+
}
|
|
232
|
+
if (verdict.decisionId !== void 0)
|
|
233
|
+
parts.push(`Why: memnox why ${verdict.decisionId}`);
|
|
234
|
+
return parts.join(" ");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// src/shell-invocation.ts
|
|
238
|
+
import { basename } from "path";
|
|
239
|
+
var SHELL_MODE = {
|
|
240
|
+
/** `-c "<line>"`: the POSIX contract, and the only form an agent actually uses. */
|
|
241
|
+
COMMAND: "command",
|
|
242
|
+
/** `-- cmd args`: how a person or a test drives the wrapper directly. */
|
|
243
|
+
ARGV: "argv",
|
|
244
|
+
/** No command at all. Hand the terminal to the real shell rather than refuse it. */
|
|
245
|
+
INTERACTIVE: "interactive"
|
|
246
|
+
};
|
|
247
|
+
function commandFlag(argument) {
|
|
248
|
+
return /^-[a-z]*c$/.test(argument);
|
|
249
|
+
}
|
|
250
|
+
function shellInvocation(argv) {
|
|
251
|
+
const separator = argv.indexOf("--");
|
|
252
|
+
if (separator !== -1) {
|
|
253
|
+
return { mode: SHELL_MODE.ARGV, argv: [...argv.slice(separator + 1)], flags: [] };
|
|
254
|
+
}
|
|
255
|
+
const flags = [];
|
|
256
|
+
for (const [index, argument] of argv.entries()) {
|
|
257
|
+
if (commandFlag(argument)) {
|
|
258
|
+
const line = argv[index + 1];
|
|
259
|
+
if (line === void 0) break;
|
|
260
|
+
return { mode: SHELL_MODE.COMMAND, line, flags };
|
|
261
|
+
}
|
|
262
|
+
if (argument.startsWith("-")) {
|
|
263
|
+
flags.push(argument);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
return { mode: SHELL_MODE.ARGV, argv: [...argv.slice(index)], flags };
|
|
267
|
+
}
|
|
268
|
+
return { mode: SHELL_MODE.INTERACTIVE, flags };
|
|
269
|
+
}
|
|
270
|
+
var REAL_SHELL_VAR = "MEMNOX_REAL_SHELL";
|
|
271
|
+
var FALLBACK_SHELL = "/bin/sh";
|
|
272
|
+
function realShell(env, self) {
|
|
273
|
+
const named = env[REAL_SHELL_VAR];
|
|
274
|
+
if (named !== void 0 && named !== "" && basename(named) !== basename(self)) {
|
|
275
|
+
return named;
|
|
276
|
+
}
|
|
277
|
+
const inherited = env["SHELL"];
|
|
278
|
+
if (inherited !== void 0 && inherited !== "" && basename(inherited) !== basename(self)) {
|
|
279
|
+
return inherited;
|
|
280
|
+
}
|
|
281
|
+
return FALLBACK_SHELL;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// src/git-credential-seam.ts
|
|
285
|
+
import { DECISION_EFFECT as DECISION_EFFECT3 } from "@memnox/core";
|
|
286
|
+
var GIT_CREDENTIAL_ACTION = "git.credential";
|
|
287
|
+
var QUIT = "quit=1\n";
|
|
288
|
+
var GitCredentialSeam = class {
|
|
289
|
+
constructor(deps) {
|
|
290
|
+
this.deps = deps;
|
|
291
|
+
}
|
|
292
|
+
deps;
|
|
293
|
+
async gate(input) {
|
|
294
|
+
const fields = parseGitInput(input);
|
|
295
|
+
const target = remoteOf(fields);
|
|
296
|
+
const request = {
|
|
297
|
+
action: GIT_CREDENTIAL_ACTION,
|
|
298
|
+
...target === void 0 ? {} : { target },
|
|
299
|
+
// LOCAL ONLY, and it never contains the credential — git has not issued one yet.
|
|
300
|
+
arguments: { ...fields },
|
|
301
|
+
...this.deps.sessionId === void 0 ? {} : { sessionId: this.deps.sessionId }
|
|
302
|
+
};
|
|
303
|
+
const verdict = await this.deps.authorizer.authorize(request);
|
|
304
|
+
if (verdict.effect === DECISION_EFFECT3.ALLOW) return { stdout: "" };
|
|
305
|
+
const where = target === void 0 ? "this remote" : target;
|
|
306
|
+
if (verdict.unreachable === true) {
|
|
307
|
+
return {
|
|
308
|
+
stdout: "",
|
|
309
|
+
message: `could not rule on ${where} \u2014 the runtime is unreachable, so git was left alone. A denied remote is reachable until it is back.`
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
return {
|
|
313
|
+
stdout: QUIT,
|
|
314
|
+
message: `no credential for ${where}: ${verdict.reason}`
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
function parseGitInput(input) {
|
|
319
|
+
const fields = {};
|
|
320
|
+
for (const line of input.split("\n")) {
|
|
321
|
+
if (line.length === 0) continue;
|
|
322
|
+
const separator = line.indexOf("=");
|
|
323
|
+
if (separator <= 0) continue;
|
|
324
|
+
const key = line.slice(0, separator).trim();
|
|
325
|
+
if (key === "password" || key === "credential") continue;
|
|
326
|
+
fields[key] = line.slice(separator + 1).trim();
|
|
327
|
+
}
|
|
328
|
+
return fields;
|
|
329
|
+
}
|
|
330
|
+
function remoteOf(fields) {
|
|
331
|
+
const host = fields["host"];
|
|
332
|
+
if (host === void 0 || host.length === 0) return void 0;
|
|
333
|
+
const protocol = fields["protocol"] ?? "https";
|
|
334
|
+
const path = fields["path"];
|
|
335
|
+
return `${protocol}://${host}${path === void 0 ? "" : `/${path}`}`;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// src/egress-seam.ts
|
|
339
|
+
import {
|
|
340
|
+
DECISION_EFFECT as DECISION_EFFECT4,
|
|
341
|
+
describeEgress as describeEgress2,
|
|
342
|
+
digest as digest2,
|
|
343
|
+
inspectEgress as inspectEgress2,
|
|
344
|
+
isAllowed as holdAllowed2
|
|
345
|
+
} from "@memnox/core";
|
|
346
|
+
var EGRESS_REQUEST_ACTION = "http.request";
|
|
347
|
+
var EGRESS_CONNECT_ACTION = "http.connect";
|
|
348
|
+
var EGRESS_BLIND_SPOTS = [
|
|
349
|
+
"the payload inside an HTTPS tunnel \u2014 the destination is gated, the body is not",
|
|
350
|
+
"any connection that does not go through this proxy",
|
|
351
|
+
"a protocol that is not HTTP or CONNECT"
|
|
352
|
+
];
|
|
353
|
+
var CARRIED_HEADERS = [
|
|
354
|
+
"authorization",
|
|
355
|
+
"cookie",
|
|
356
|
+
"x-api-key",
|
|
357
|
+
"proxy-authorization"
|
|
358
|
+
];
|
|
359
|
+
var EgressSeam = class {
|
|
360
|
+
constructor(deps) {
|
|
361
|
+
this.deps = deps;
|
|
362
|
+
}
|
|
363
|
+
deps;
|
|
364
|
+
async gateRequest(attempt) {
|
|
365
|
+
const fields = fieldsOf(attempt);
|
|
366
|
+
const inspection = inspectEgress2({ destination: attempt.url, fields });
|
|
367
|
+
if (inspection.findings.length > 0) {
|
|
368
|
+
return { allowed: false, message: describeEgress2(inspection) };
|
|
369
|
+
}
|
|
370
|
+
return this.rule({
|
|
371
|
+
action: EGRESS_REQUEST_ACTION,
|
|
372
|
+
target: attempt.url,
|
|
373
|
+
arguments: fields,
|
|
374
|
+
...this.deps.sessionId === void 0 ? {} : { sessionId: this.deps.sessionId }
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* All that is knowable about a tunnel is where it goes. Ruling on the destination and
|
|
379
|
+
* saying plainly that the body is unseen beats pretending to inspect it.
|
|
380
|
+
*/
|
|
381
|
+
async gateConnect(authority) {
|
|
382
|
+
return this.rule({
|
|
383
|
+
action: EGRESS_CONNECT_ACTION,
|
|
384
|
+
target: authority,
|
|
385
|
+
...this.deps.sessionId === void 0 ? {} : { sessionId: this.deps.sessionId }
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
async rule(request) {
|
|
389
|
+
const verdict = await this.deps.authorizer.authorize(request);
|
|
390
|
+
if (verdict.effect === DECISION_EFFECT4.ALLOW) return { allowed: true };
|
|
391
|
+
if (verdict.effect === DECISION_EFFECT4.ASK) {
|
|
392
|
+
const asked = await this.ask(request, verdict);
|
|
393
|
+
if (asked === null) return { allowed: true };
|
|
394
|
+
return asked;
|
|
395
|
+
}
|
|
396
|
+
return { allowed: false, message: describe2(verdict) };
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Puts an ask to a person. Null when it was allowed and the request may go.
|
|
400
|
+
*
|
|
401
|
+
* Without this an `ask` rule refused the request outright and told the reader "you
|
|
402
|
+
* chose to be asked about this" while nobody had been asked — the rule's own words
|
|
403
|
+
* arguing with what had just happened to them.
|
|
404
|
+
*/
|
|
405
|
+
async ask(request, verdict) {
|
|
406
|
+
const hold = this.deps.hold;
|
|
407
|
+
if (hold === void 0) {
|
|
408
|
+
return {
|
|
409
|
+
allowed: false,
|
|
410
|
+
message: `${describe2(verdict)} Nobody could be asked, so it did not go.`
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
const result = await hold.hold({
|
|
414
|
+
sessionId: this.deps.sessionId ?? "ses_local",
|
|
415
|
+
agent: "an agent",
|
|
416
|
+
operation: request.action,
|
|
417
|
+
fingerprint: digest2(`${request.action}:${request.target ?? ""}`),
|
|
418
|
+
reason: verdict.reason,
|
|
419
|
+
...request.target === void 0 ? {} : { target: request.target }
|
|
420
|
+
});
|
|
421
|
+
return holdAllowed2(result) ? null : { allowed: false, message: describe2(verdict) };
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
function fieldsOf(attempt) {
|
|
425
|
+
const fields = { method: attempt.method, url: attempt.url };
|
|
426
|
+
for (const name of CARRIED_HEADERS) {
|
|
427
|
+
const value = attempt.headers === void 0 ? void 0 : attempt.headers[name];
|
|
428
|
+
if (value !== void 0 && value.length > 0) fields[name] = value;
|
|
429
|
+
}
|
|
430
|
+
if (attempt.body !== void 0 && attempt.body.length > 0)
|
|
431
|
+
fields["body"] = attempt.body;
|
|
432
|
+
return fields;
|
|
433
|
+
}
|
|
434
|
+
function describe2(verdict) {
|
|
435
|
+
const parts = [verdict.reason];
|
|
436
|
+
if (verdict.alternative !== void 0) {
|
|
437
|
+
const target = verdict.alternative.resource === void 0 ? "" : ` ${verdict.alternative.resource}`;
|
|
438
|
+
parts.push(
|
|
439
|
+
`Instead: ${verdict.alternative.action}${target} \u2014 ${verdict.alternative.note}`
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
if (verdict.approvalId !== void 0) {
|
|
443
|
+
parts.push(`Ask a person: memnox approvals resolve ${verdict.approvalId} --by <you>`);
|
|
444
|
+
}
|
|
445
|
+
if (verdict.decisionId !== void 0) {
|
|
446
|
+
parts.push(`Why: memnox why ${verdict.decisionId}`);
|
|
447
|
+
}
|
|
448
|
+
return parts.join(" ");
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// src/interceptor.ts
|
|
452
|
+
import { basename as basename2, delimiter, join as join2 } from "path";
|
|
453
|
+
import {
|
|
454
|
+
DECISION_EFFECT as DECISION_EFFECT5,
|
|
455
|
+
describeHold,
|
|
456
|
+
digest as digest3,
|
|
457
|
+
evidenceFor,
|
|
458
|
+
HOLD_OUTCOME,
|
|
459
|
+
isAllowed as holdAllowed3,
|
|
460
|
+
MEMNOX_HOME,
|
|
461
|
+
refusalShapeFor,
|
|
462
|
+
renderEvidence,
|
|
463
|
+
resolveAction
|
|
464
|
+
} from "@memnox/core";
|
|
465
|
+
var INTERCEPTOR_DIR = "bin";
|
|
466
|
+
function interceptorDirFor(home) {
|
|
467
|
+
return join2(home, MEMNOX_HOME, INTERCEPTOR_DIR);
|
|
468
|
+
}
|
|
469
|
+
function realPath(path, home) {
|
|
470
|
+
const ours = interceptorDirFor(home);
|
|
471
|
+
return path.split(delimiter).filter((entry) => entry !== "" && entry !== ours).join(delimiter);
|
|
472
|
+
}
|
|
473
|
+
var INTERCEPT_BINARY = "memnox-intercept";
|
|
474
|
+
function invokedFor(argv) {
|
|
475
|
+
const binary = argv[2];
|
|
476
|
+
if (binary === void 0 || basename2(binary) === INTERCEPT_BINARY) return null;
|
|
477
|
+
return { binary: basename2(binary), args: [...argv.slice(3)] };
|
|
478
|
+
}
|
|
479
|
+
async function ruleOnCommand(binary, args, deps) {
|
|
480
|
+
const verdict = verdictFor(binary, args, deps.env ?? {});
|
|
481
|
+
const argsDigest = digest3(args.join(" "));
|
|
482
|
+
const base = {
|
|
483
|
+
allowed: true,
|
|
484
|
+
binary,
|
|
485
|
+
args: [...args],
|
|
486
|
+
action: verdict.action,
|
|
487
|
+
class: verdict.class,
|
|
488
|
+
argsDigest,
|
|
489
|
+
...verdict.target === void 0 ? {} : { target: verdict.target }
|
|
490
|
+
};
|
|
491
|
+
const gate = deps.gate;
|
|
492
|
+
if (gate === void 0) return base;
|
|
493
|
+
const decision = gate.evaluate({
|
|
494
|
+
action: verdict.action,
|
|
495
|
+
...verdict.target === void 0 ? {} : { target: verdict.target }
|
|
496
|
+
});
|
|
497
|
+
if (decision.effect === DECISION_EFFECT5.ALLOW) return base;
|
|
498
|
+
const shown = renderEvidence(
|
|
499
|
+
evidenceFor({
|
|
500
|
+
matched: decision.matchedPolicies,
|
|
501
|
+
moment: (/* @__PURE__ */ new Date()).toISOString(),
|
|
502
|
+
...deps.overlays === void 0 ? {} : { overlays: deps.overlays }
|
|
503
|
+
})
|
|
504
|
+
);
|
|
505
|
+
const matched = decision.matchedPolicies[0];
|
|
506
|
+
const decided = {
|
|
507
|
+
reason: decision.reason,
|
|
508
|
+
...matched === void 0 ? {} : { rule: { name: matched.name, layer: "project", file: "policy" } }
|
|
509
|
+
};
|
|
510
|
+
if (decision.effect === DECISION_EFFECT5.ASK) {
|
|
511
|
+
const held = await askPerson(
|
|
512
|
+
verdict,
|
|
513
|
+
decision.reason,
|
|
514
|
+
shown,
|
|
515
|
+
[binary, ...args],
|
|
516
|
+
deps
|
|
517
|
+
);
|
|
518
|
+
if (held === null) return { ...base, ...decided };
|
|
519
|
+
if (typeof held !== "string") {
|
|
520
|
+
return {
|
|
521
|
+
...base,
|
|
522
|
+
...decided,
|
|
523
|
+
allowed: false,
|
|
524
|
+
edited: held.edited,
|
|
525
|
+
message: held.message
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
return { ...base, ...decided, allowed: false, message: held };
|
|
529
|
+
}
|
|
530
|
+
return {
|
|
531
|
+
...base,
|
|
532
|
+
...decided,
|
|
533
|
+
allowed: false,
|
|
534
|
+
message: [refusal(verdict, decision), ...shown].join("\n")
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
async function askPerson(verdict, reason, evidence, command, deps) {
|
|
538
|
+
const hold = deps.hold;
|
|
539
|
+
const request = {
|
|
540
|
+
sessionId: deps.sessionId ?? "ses_local",
|
|
541
|
+
agent: deps.agent ?? "an agent",
|
|
542
|
+
operation: verdict.action,
|
|
543
|
+
fingerprint: digest3(`${verdict.action}:${verdict.target ?? ""}`),
|
|
544
|
+
reason,
|
|
545
|
+
evidence,
|
|
546
|
+
command: command.join(" "),
|
|
547
|
+
...verdict.target === void 0 ? {} : { target: verdict.target }
|
|
548
|
+
};
|
|
549
|
+
if (hold === void 0) {
|
|
550
|
+
return `${reason}
|
|
551
|
+
Nobody could be asked, so it was denied. Run the agent under "memnox run".`;
|
|
552
|
+
}
|
|
553
|
+
const result = await hold.hold(request);
|
|
554
|
+
if (holdAllowed3(result)) return null;
|
|
555
|
+
if (result.outcome === HOLD_OUTCOME.EDITED && result.edited !== void 0) {
|
|
556
|
+
return { message: describeHold(result, request), edited: result.edited };
|
|
557
|
+
}
|
|
558
|
+
return describeHold(result, request);
|
|
559
|
+
}
|
|
560
|
+
function refusal(verdict, decision) {
|
|
561
|
+
const alternative = decision.alternative;
|
|
562
|
+
const instead = alternative === void 0 ? "" : `
|
|
563
|
+
Instead: ${alternative.action}${alternative.resource === void 0 ? "" : ` ${alternative.resource}`} \u2014 ${alternative.note}`;
|
|
564
|
+
const { guidance } = refusalShapeFor(DECISION_EFFECT5.DENY, decision.reason);
|
|
565
|
+
return `Denied by Memnox: ${decision.reason}
|
|
566
|
+
(${verdict.because})${instead}
|
|
567
|
+
${guidance}`;
|
|
568
|
+
}
|
|
569
|
+
function resolveReal(binary, path, exists) {
|
|
570
|
+
if (basename2(binary) === INTERCEPT_BINARY) return null;
|
|
571
|
+
for (const entry of path.split(delimiter)) {
|
|
572
|
+
if (entry === "") continue;
|
|
573
|
+
const candidate = join2(entry, binary);
|
|
574
|
+
if (basename2(candidate) === INTERCEPT_BINARY) continue;
|
|
575
|
+
if (exists(candidate)) return candidate;
|
|
576
|
+
}
|
|
577
|
+
return null;
|
|
578
|
+
}
|
|
579
|
+
function verdictFor(binary, args, env = {}) {
|
|
580
|
+
const resolved = resolveAction(binary, args, env);
|
|
581
|
+
return {
|
|
582
|
+
action: resolved.action,
|
|
583
|
+
class: resolved.class,
|
|
584
|
+
because: resolved.because,
|
|
585
|
+
...resolved.target === void 0 ? {} : { target: resolved.target }
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// src/interceptor-install.ts
|
|
590
|
+
import { existsSync } from "fs";
|
|
591
|
+
import { chmod, mkdir, readdir, rm, writeFile } from "fs/promises";
|
|
592
|
+
import { join as join3 } from "path";
|
|
593
|
+
import { interceptableBinaries } from "@memnox/core";
|
|
594
|
+
function scriptFor(binary, interceptBinary) {
|
|
595
|
+
return [
|
|
596
|
+
"#!/bin/sh",
|
|
597
|
+
`# Memnox interceptor for ${binary}. Remove this file, or run "memnox uninstall", to undo.`,
|
|
598
|
+
`exec "${interceptBinary}" "${binary}" "$@"`,
|
|
599
|
+
""
|
|
600
|
+
].join("\n");
|
|
601
|
+
}
|
|
602
|
+
async function installInterceptors(home, interceptBinary, seams = {}) {
|
|
603
|
+
const directory = interceptorDirFor(home);
|
|
604
|
+
await mkdir(directory, { recursive: true, mode: 448 });
|
|
605
|
+
const path = realPath(seams.path ?? process.env["PATH"] ?? "", home);
|
|
606
|
+
const exists = seams.exists ?? existsSync;
|
|
607
|
+
const installed = [];
|
|
608
|
+
const absent = [];
|
|
609
|
+
for (const binary of interceptableBinaries()) {
|
|
610
|
+
if (resolveReal(binary, path, exists) === null) {
|
|
611
|
+
absent.push(binary);
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
const scriptPath = join3(directory, binary);
|
|
615
|
+
await writeFile(scriptPath, scriptFor(binary, interceptBinary), {
|
|
616
|
+
encoding: "utf8",
|
|
617
|
+
mode: 448
|
|
618
|
+
});
|
|
619
|
+
await chmod(scriptPath, 448);
|
|
620
|
+
installed.push(binary);
|
|
621
|
+
}
|
|
622
|
+
return {
|
|
623
|
+
directory,
|
|
624
|
+
installed,
|
|
625
|
+
absent,
|
|
626
|
+
pathLine: `export PATH="${directory}:$PATH"`
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
async function removeInterceptors(home) {
|
|
630
|
+
const directory = interceptorDirFor(home);
|
|
631
|
+
let names;
|
|
632
|
+
try {
|
|
633
|
+
names = await readdir(directory);
|
|
634
|
+
} catch {
|
|
635
|
+
return [];
|
|
636
|
+
}
|
|
637
|
+
for (const name of names) await rm(join3(directory, name), { force: true });
|
|
638
|
+
await rm(directory, { recursive: true, force: true });
|
|
639
|
+
return names;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// src/git-hooks.ts
|
|
643
|
+
import { mkdir as mkdir2, readFile, rm as rm2, writeFile as writeFile2, chmod as chmod2 } from "fs/promises";
|
|
644
|
+
import { join as join4 } from "path";
|
|
645
|
+
var HOOKS = ["pre-push", "pre-commit"];
|
|
646
|
+
var HOOK_MARKER = "# installed by memnox";
|
|
647
|
+
var HOOK_ACTIONS = {
|
|
648
|
+
"pre-push": "git.push-force",
|
|
649
|
+
"pre-commit": "git.commit"
|
|
650
|
+
};
|
|
651
|
+
function bodyFor(hook, binary) {
|
|
652
|
+
const action = HOOK_ACTIONS[hook];
|
|
653
|
+
const lines = [
|
|
654
|
+
"#!/bin/sh",
|
|
655
|
+
HOOK_MARKER,
|
|
656
|
+
'# Remove this file, or run "memnox uninstall", to undo.',
|
|
657
|
+
"",
|
|
658
|
+
`MEMNOX="${binary}"`,
|
|
659
|
+
'command -v "$MEMNOX" >/dev/null 2>&1 || MEMNOX=memnox',
|
|
660
|
+
""
|
|
661
|
+
];
|
|
662
|
+
if (hook === "pre-push") {
|
|
663
|
+
lines.push(
|
|
664
|
+
"# Only a force push. git names no flag, so it is read off the refs it is given:",
|
|
665
|
+
"# a remote tip that is not an ancestor of the local one is history being rewritten.",
|
|
666
|
+
"forced=0",
|
|
667
|
+
"while read -r _local_ref local_sha _remote_ref remote_sha; do",
|
|
668
|
+
' [ -z "$remote_sha" ] && continue',
|
|
669
|
+
' case "$remote_sha" in *[!0]*) ;; *) continue ;; esac',
|
|
670
|
+
' git merge-base --is-ancestor "$remote_sha" "$local_sha" 2>/dev/null || forced=1',
|
|
671
|
+
"done",
|
|
672
|
+
'[ "$forced" = "0" ] && exit 0',
|
|
673
|
+
""
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
lines.push(
|
|
677
|
+
`"$MEMNOX" policy test "${action}" >/dev/null 2>&1`,
|
|
678
|
+
"status=$?",
|
|
679
|
+
'[ "$status" = "0" ] && exit 0',
|
|
680
|
+
"",
|
|
681
|
+
"# 126 and 127 are the shell saying it never ran: not found, or found and not",
|
|
682
|
+
"# runnable because its interpreter is missing. A hook that ruled on nothing must",
|
|
683
|
+
"# not block, or installing Memnox and then opening a shell without it on PATH \u2014",
|
|
684
|
+
'# which is every shell, after "npx memnox" \u2014 would stop every commit in the repo.',
|
|
685
|
+
'if [ "$status" = "127" ] || [ "$status" = "126" ]; then',
|
|
686
|
+
' echo "memnox: could not run here, so this hook ruled on nothing" >&2',
|
|
687
|
+
" exit 0",
|
|
688
|
+
"fi",
|
|
689
|
+
"",
|
|
690
|
+
"# Non-zero from Memnox itself stops the operation, which is the point of a hook.",
|
|
691
|
+
`"$MEMNOX" policy test "${action}" >&2`,
|
|
692
|
+
"exit 1",
|
|
693
|
+
""
|
|
694
|
+
);
|
|
695
|
+
return lines.join("\n");
|
|
696
|
+
}
|
|
697
|
+
function defaultBinary() {
|
|
698
|
+
const [, script] = process.argv;
|
|
699
|
+
return script === void 0 || script === "" ? "memnox" : script;
|
|
700
|
+
}
|
|
701
|
+
async function existingHook(path) {
|
|
702
|
+
try {
|
|
703
|
+
return await readFile(path, "utf8");
|
|
704
|
+
} catch {
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
async function installGitHooks(repoDir, binary = defaultBinary()) {
|
|
709
|
+
const hooksDir = join4(repoDir, ".git", "hooks");
|
|
710
|
+
await mkdir2(hooksDir, { recursive: true });
|
|
711
|
+
const report = { installed: [], skipped: [] };
|
|
712
|
+
for (const hook of HOOKS) {
|
|
713
|
+
const path = join4(hooksDir, hook);
|
|
714
|
+
const existing = await existingHook(path);
|
|
715
|
+
if (existing !== null && !existing.includes(HOOK_MARKER)) {
|
|
716
|
+
report.skipped.push({ hook, because: "a hook is already there and is not ours" });
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
await writeFile2(path, bodyFor(hook, binary), { encoding: "utf8", mode: 493 });
|
|
720
|
+
await chmod2(path, 493);
|
|
721
|
+
report.installed.push(hook);
|
|
722
|
+
}
|
|
723
|
+
return report;
|
|
724
|
+
}
|
|
725
|
+
async function removeGitHooks(repoDir) {
|
|
726
|
+
const hooksDir = join4(repoDir, ".git", "hooks");
|
|
727
|
+
const removed = [];
|
|
728
|
+
for (const hook of HOOKS) {
|
|
729
|
+
const path = join4(hooksDir, hook);
|
|
730
|
+
const existing = await existingHook(path);
|
|
731
|
+
if (existing === null || !existing.includes(HOOK_MARKER)) continue;
|
|
732
|
+
await rm2(path, { force: true });
|
|
733
|
+
removed.push(hook);
|
|
734
|
+
}
|
|
735
|
+
return removed;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// src/daemon-client.ts
|
|
739
|
+
import { connect } from "net";
|
|
740
|
+
import {
|
|
741
|
+
DAEMON_METHOD,
|
|
742
|
+
decodeResponse,
|
|
743
|
+
encode,
|
|
744
|
+
LineReader,
|
|
745
|
+
socketPathFor
|
|
746
|
+
} from "@memnox/core";
|
|
747
|
+
var DAEMON_TIMEOUT_MS = 250;
|
|
748
|
+
function askDaemon(home, options) {
|
|
749
|
+
return speak(home, options.timeoutMs, {
|
|
750
|
+
id: 1,
|
|
751
|
+
method: DAEMON_METHOD.EVALUATE,
|
|
752
|
+
action: options.action,
|
|
753
|
+
...options.target === void 0 ? {} : { target: options.target },
|
|
754
|
+
...options.sessionId === void 0 ? {} : { sessionId: options.sessionId }
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
function reportToDaemon(home, options) {
|
|
758
|
+
return speak(home, options.timeoutMs, {
|
|
759
|
+
id: 1,
|
|
760
|
+
method: DAEMON_METHOD.RECORD,
|
|
761
|
+
action: options.action,
|
|
762
|
+
...options.target === void 0 ? {} : { target: options.target },
|
|
763
|
+
...options.sessionId === void 0 ? {} : { sessionId: options.sessionId },
|
|
764
|
+
...options.exitCode === void 0 ? {} : { exitCode: options.exitCode },
|
|
765
|
+
...options.outOfScope === void 0 ? {} : { outOfScope: options.outOfScope },
|
|
766
|
+
...options.costUsd === void 0 ? {} : { costUsd: options.costUsd }
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
function askStatus(home, sessionId, timeoutMs) {
|
|
770
|
+
return speak(home, timeoutMs, {
|
|
771
|
+
id: 1,
|
|
772
|
+
method: DAEMON_METHOD.STATUS,
|
|
773
|
+
sessionId
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
function speak(home, timeout, message) {
|
|
777
|
+
const path = socketPathFor(home);
|
|
778
|
+
const timeoutMs = timeout ?? DAEMON_TIMEOUT_MS;
|
|
779
|
+
return new Promise((resolve2) => {
|
|
780
|
+
let settled = false;
|
|
781
|
+
const finish = (value) => {
|
|
782
|
+
if (settled) return;
|
|
783
|
+
settled = true;
|
|
784
|
+
clearTimeout(timer);
|
|
785
|
+
socket.destroy();
|
|
786
|
+
resolve2(value);
|
|
787
|
+
};
|
|
788
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
789
|
+
timer.unref?.();
|
|
790
|
+
const socket = connect(path, () => {
|
|
791
|
+
socket.write(encode(message));
|
|
792
|
+
});
|
|
793
|
+
const reader = new LineReader();
|
|
794
|
+
socket.setEncoding("utf8");
|
|
795
|
+
socket.on("data", (chunk) => {
|
|
796
|
+
for (const line of reader.push(chunk)) finish(decodeResponse(line));
|
|
797
|
+
});
|
|
798
|
+
socket.on("error", () => finish(null));
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// src/browser-seam.ts
|
|
803
|
+
import {
|
|
804
|
+
BrowserHosts,
|
|
805
|
+
DECISION_EFFECT as DECISION_EFFECT6,
|
|
806
|
+
describeHold as describeHold2,
|
|
807
|
+
digest as digest4,
|
|
808
|
+
isAllowed as holdAllowed4,
|
|
809
|
+
navigationHost
|
|
810
|
+
} from "@memnox/core";
|
|
811
|
+
var BROWSER_ACTION = "browser.navigate";
|
|
812
|
+
var BrowserSeam = class {
|
|
813
|
+
constructor(deps = {}) {
|
|
814
|
+
this.deps = deps;
|
|
815
|
+
this.hosts = deps.hosts ?? new BrowserHosts();
|
|
816
|
+
}
|
|
817
|
+
deps;
|
|
818
|
+
hosts;
|
|
819
|
+
async navigate(url) {
|
|
820
|
+
const host = navigationHost(url);
|
|
821
|
+
if (host === null) return { allowed: true, host: null };
|
|
822
|
+
const sessionId = this.deps.sessionId ?? "ses_local";
|
|
823
|
+
if (this.hosts.seen(sessionId, host)) {
|
|
824
|
+
return { allowed: true, host, remembered: true };
|
|
825
|
+
}
|
|
826
|
+
const gate = this.deps.gate;
|
|
827
|
+
const verdict = gate === void 0 ? { effect: DECISION_EFFECT6.ALLOW, reason: "no rules configured" } : gate.evaluate({ action: BROWSER_ACTION, target: host });
|
|
828
|
+
if (verdict.effect === DECISION_EFFECT6.ALLOW) {
|
|
829
|
+
this.hosts.remember(sessionId, host);
|
|
830
|
+
return { allowed: true, host };
|
|
831
|
+
}
|
|
832
|
+
if (verdict.effect === DECISION_EFFECT6.DENY) {
|
|
833
|
+
return { allowed: false, host, message: `Denied by Memnox: ${verdict.reason}` };
|
|
834
|
+
}
|
|
835
|
+
const request = {
|
|
836
|
+
sessionId,
|
|
837
|
+
agent: this.deps.agent ?? "an agent",
|
|
838
|
+
operation: BROWSER_ACTION,
|
|
839
|
+
target: host,
|
|
840
|
+
fingerprint: digest4(`${BROWSER_ACTION}:${host}`),
|
|
841
|
+
reason: verdict.reason
|
|
842
|
+
};
|
|
843
|
+
const hold = this.deps.hold;
|
|
844
|
+
if (hold === void 0) {
|
|
845
|
+
return {
|
|
846
|
+
allowed: false,
|
|
847
|
+
host,
|
|
848
|
+
message: `${verdict.reason}
|
|
849
|
+
Nobody could be asked, so it was denied. Run the agent under "memnox run".`
|
|
850
|
+
};
|
|
851
|
+
}
|
|
852
|
+
const result = await hold.hold(request);
|
|
853
|
+
if (!holdAllowed4(result)) {
|
|
854
|
+
return { allowed: false, host, message: describeHold2(result, request) };
|
|
855
|
+
}
|
|
856
|
+
this.hosts.remember(sessionId, host);
|
|
857
|
+
return { allowed: true, host };
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
// src/record.ts
|
|
862
|
+
import { randomUUID } from "crypto";
|
|
863
|
+
import {
|
|
864
|
+
ACTOR_TYPE,
|
|
865
|
+
ENFORCEMENT_MODE,
|
|
866
|
+
EVENT_SCHEMA_VERSION,
|
|
867
|
+
EVENT_SURFACE,
|
|
868
|
+
TOOL_CLASS
|
|
869
|
+
} from "@memnox/core";
|
|
870
|
+
var CLASSES = Object.values(TOOL_CLASS);
|
|
871
|
+
function classOf(value) {
|
|
872
|
+
return CLASSES.includes(value) ? value : TOOL_CLASS.UNKNOWN;
|
|
873
|
+
}
|
|
874
|
+
function eventFor(input) {
|
|
875
|
+
const { outcome } = input;
|
|
876
|
+
return {
|
|
877
|
+
id: `evt_${randomUUID().replace(/-/g, "").slice(0, 20)}`,
|
|
878
|
+
schemaVersion: EVENT_SCHEMA_VERSION,
|
|
879
|
+
at: input.at,
|
|
880
|
+
sessionId: input.sessionId ?? "ses_local",
|
|
881
|
+
agent: input.agent ?? "an agent",
|
|
882
|
+
actorType: ACTOR_TYPE.AGENT,
|
|
883
|
+
surface: outcome.binary === "git" ? EVENT_SURFACE.GIT : EVENT_SURFACE.SHELL,
|
|
884
|
+
operation: outcome.action,
|
|
885
|
+
class: classOf(outcome.class),
|
|
886
|
+
effect: input.effect,
|
|
887
|
+
mode: ENFORCEMENT_MODE.ENFORCE,
|
|
888
|
+
reason: input.reason,
|
|
889
|
+
// A digest, never the arguments: an argument list is where a secret would be.
|
|
890
|
+
argsDigest: outcome.argsDigest,
|
|
891
|
+
...outcome.target === void 0 ? {} : { target: outcome.target },
|
|
892
|
+
...input.rule === void 0 ? {} : { rule: input.rule },
|
|
893
|
+
...input.policyHash === void 0 ? {} : { policyHash: input.policyHash },
|
|
894
|
+
...input.bundleHash === void 0 ? {} : { bundleHash: input.bundleHash },
|
|
895
|
+
...input.conditionsInForce === void 0 ? {} : { conditionsInForce: input.conditionsInForce },
|
|
896
|
+
...input.exitCode === void 0 ? {} : { exitCode: input.exitCode },
|
|
897
|
+
...input.durationMs === void 0 ? {} : { durationMs: input.durationMs }
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
async function record(sink, input) {
|
|
901
|
+
if (sink === null) return;
|
|
902
|
+
try {
|
|
903
|
+
await sink.append(eventFor(input));
|
|
904
|
+
} catch {
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// src/breaker-seam.ts
|
|
909
|
+
import {
|
|
910
|
+
breachIn,
|
|
911
|
+
DEFAULT_THRESHOLDS,
|
|
912
|
+
describePause,
|
|
913
|
+
outcomesFrom,
|
|
914
|
+
REPLAY_LIMIT,
|
|
915
|
+
SessionPauses,
|
|
916
|
+
SqliteEventStore
|
|
917
|
+
} from "@memnox/core";
|
|
918
|
+
async function pauseHolding(home, sessionId) {
|
|
919
|
+
if (sessionId === void 0 || sessionId === "") return null;
|
|
920
|
+
try {
|
|
921
|
+
return await new SessionPauses(home).inForce(sessionId);
|
|
922
|
+
} catch {
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
function pauseMessage(pause) {
|
|
927
|
+
return [
|
|
928
|
+
`Paused by Memnox: ${describePause(pause)}`,
|
|
929
|
+
`Resume with "memnox resume ${pause.sessionId}".`
|
|
930
|
+
].join("\n");
|
|
931
|
+
}
|
|
932
|
+
async function resumedAt(home, sessionId) {
|
|
933
|
+
try {
|
|
934
|
+
const pause = await new SessionPauses(home).read(sessionId);
|
|
935
|
+
return pause?.resumedAt ?? null;
|
|
936
|
+
} catch {
|
|
937
|
+
return null;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
async function observeSession(options) {
|
|
941
|
+
const { home, sessionId } = options;
|
|
942
|
+
if (sessionId === void 0 || sessionId === "") return null;
|
|
943
|
+
try {
|
|
944
|
+
const since = await resumedAt(home, sessionId);
|
|
945
|
+
const store = SqliteEventStore.forHome(home);
|
|
946
|
+
let events;
|
|
947
|
+
try {
|
|
948
|
+
events = await store.query({
|
|
949
|
+
sessionId,
|
|
950
|
+
limit: REPLAY_LIMIT,
|
|
951
|
+
...since === null ? {} : { since }
|
|
952
|
+
});
|
|
953
|
+
} finally {
|
|
954
|
+
store.close();
|
|
955
|
+
}
|
|
956
|
+
const breach = breachIn(
|
|
957
|
+
outcomesFrom(events),
|
|
958
|
+
options.thresholds ?? DEFAULT_THRESHOLDS
|
|
959
|
+
);
|
|
960
|
+
if (breach === null) return null;
|
|
961
|
+
const last = events[events.length - 1];
|
|
962
|
+
const pause = {
|
|
963
|
+
sessionId,
|
|
964
|
+
signal: breach.signal,
|
|
965
|
+
reason: breach.reason,
|
|
966
|
+
reached: breach.reached,
|
|
967
|
+
ceiling: breach.ceiling,
|
|
968
|
+
pausedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
969
|
+
...last === void 0 ? {} : { lastAction: last.operation }
|
|
970
|
+
};
|
|
971
|
+
await new SessionPauses(home).pause(pause);
|
|
972
|
+
return pause;
|
|
973
|
+
} catch {
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// src/seam-runtime.ts
|
|
979
|
+
import { execFileSync } from "child_process";
|
|
980
|
+
import { statSync } from "fs";
|
|
981
|
+
import { homedir as homedir2 } from "os";
|
|
982
|
+
import { resolve } from "path";
|
|
983
|
+
import {
|
|
984
|
+
CloudLeases,
|
|
985
|
+
holdFor,
|
|
986
|
+
LeaseGate,
|
|
987
|
+
LeaseRegistry,
|
|
988
|
+
SESSION_VAR as SESSION_VAR2,
|
|
989
|
+
TtyLeasePrompt
|
|
990
|
+
} from "@memnox/core";
|
|
991
|
+
var log = (message) => {
|
|
992
|
+
process.stderr.write(`[memnox] ${message}
|
|
993
|
+
`);
|
|
994
|
+
};
|
|
995
|
+
async function buildAuthorizer() {
|
|
996
|
+
const config = await readHookConfig(process.env, homedir2());
|
|
997
|
+
const gate = await loadHookGate(config);
|
|
998
|
+
if (gate === null) {
|
|
999
|
+
log(`no gate configured \u2014 set ${ENV_POLICIES}`);
|
|
1000
|
+
}
|
|
1001
|
+
return new HookAuthorizer({
|
|
1002
|
+
...gate === null ? {} : { gate },
|
|
1003
|
+
log
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
async function readStdin() {
|
|
1007
|
+
const chunks = [];
|
|
1008
|
+
process.stdin.setEncoding("utf8");
|
|
1009
|
+
for await (const chunk of process.stdin) chunks.push(String(chunk));
|
|
1010
|
+
return chunks.join("");
|
|
1011
|
+
}
|
|
1012
|
+
function buildLeases(cwd = process.cwd()) {
|
|
1013
|
+
const root = repositoryRoot(cwd);
|
|
1014
|
+
if (root === null) return void 0;
|
|
1015
|
+
return {
|
|
1016
|
+
gate: new LeaseGate({
|
|
1017
|
+
registry: new LeaseRegistry(homedir2()),
|
|
1018
|
+
/* The workspace's register too, so two machines on one repository stop being a
|
|
1019
|
+
coin flip. It makes no call at all without an account file, and an
|
|
1020
|
+
unreachable control plane never stops a write. */
|
|
1021
|
+
shared: new CloudLeases(homedir2()),
|
|
1022
|
+
prompt: new TtyLeasePrompt(),
|
|
1023
|
+
now: () => (/* @__PURE__ */ new Date()).toISOString()
|
|
1024
|
+
}),
|
|
1025
|
+
holder: {
|
|
1026
|
+
agent: process.env[ENV_AGENT_NAME] ?? DEFAULT_AGENT_NAME,
|
|
1027
|
+
/* The session `memnox run` set. Without one, every command would be its own
|
|
1028
|
+
session and a lease would never survive to the next line. */
|
|
1029
|
+
sessionId: process.env[SESSION_VAR2] ?? `ses_pid_${process.ppid}`,
|
|
1030
|
+
/* The agent, not this wrapper. A wrapper exits the moment its command does, so
|
|
1031
|
+
holding its own pid would mark every lease abandoned as soon as it was taken. */
|
|
1032
|
+
pid: process.ppid
|
|
1033
|
+
},
|
|
1034
|
+
repositoryRoot: root,
|
|
1035
|
+
isDirectory: (path) => {
|
|
1036
|
+
try {
|
|
1037
|
+
return statSync(resolve(root, path)).isDirectory();
|
|
1038
|
+
} catch {
|
|
1039
|
+
return false;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
function repositoryRoot(cwd) {
|
|
1045
|
+
try {
|
|
1046
|
+
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
|
1047
|
+
cwd,
|
|
1048
|
+
encoding: "utf8",
|
|
1049
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
1050
|
+
}).trim();
|
|
1051
|
+
} catch {
|
|
1052
|
+
return null;
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
function buildHold(timeoutMs) {
|
|
1056
|
+
return holdFor({
|
|
1057
|
+
home: homedir2(),
|
|
1058
|
+
/* Opening /dev/tty on a headless box succeeds often enough that asking there
|
|
1059
|
+
would swallow the question, so this is asked of stdin instead. */
|
|
1060
|
+
interactive: process.stdin.isTTY === true,
|
|
1061
|
+
announce: log,
|
|
1062
|
+
...timeoutMs === void 0 ? {} : { timeoutMs }
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
export {
|
|
1066
|
+
BROWSER_ACTION,
|
|
1067
|
+
BrowserSeam,
|
|
1068
|
+
DAEMON_TIMEOUT_MS,
|
|
1069
|
+
DEFAULT_AGENT_NAME,
|
|
1070
|
+
EGRESS_ACTIONS,
|
|
1071
|
+
EGRESS_BLIND_SPOTS,
|
|
1072
|
+
EGRESS_CONNECT_ACTION,
|
|
1073
|
+
EGRESS_DEFAULT_PORT,
|
|
1074
|
+
EGRESS_MAX_BODY_BYTES,
|
|
1075
|
+
EGRESS_REQUEST_ACTION,
|
|
1076
|
+
ENV_AGENT_NAME,
|
|
1077
|
+
ENV_AGENT_ROLE,
|
|
1078
|
+
ENV_POLICIES,
|
|
1079
|
+
EgressSeam,
|
|
1080
|
+
FALLBACK_SHELL,
|
|
1081
|
+
GIT_CREDENTIAL_ACTION,
|
|
1082
|
+
GitCredentialSeam,
|
|
1083
|
+
HOOKS,
|
|
1084
|
+
HOOK_MARKER,
|
|
1085
|
+
HookAuthorizer,
|
|
1086
|
+
INTERCEPTOR_DIR,
|
|
1087
|
+
INTERCEPT_BINARY,
|
|
1088
|
+
POLICY_PATH_SEPARATOR,
|
|
1089
|
+
REAL_SHELL_VAR,
|
|
1090
|
+
SHELL_ACTION,
|
|
1091
|
+
SHELL_EXIT_OK,
|
|
1092
|
+
SHELL_EXIT_WITHHELD,
|
|
1093
|
+
SHELL_MODE,
|
|
1094
|
+
ShellSeam,
|
|
1095
|
+
askDaemon,
|
|
1096
|
+
askStatus,
|
|
1097
|
+
buildAuthorizer,
|
|
1098
|
+
buildHold,
|
|
1099
|
+
buildLeases,
|
|
1100
|
+
eventFor,
|
|
1101
|
+
installGitHooks,
|
|
1102
|
+
installInterceptors,
|
|
1103
|
+
interceptorDirFor,
|
|
1104
|
+
invokedFor,
|
|
1105
|
+
loadHookGate,
|
|
1106
|
+
log,
|
|
1107
|
+
observeSession,
|
|
1108
|
+
parseGitInput,
|
|
1109
|
+
pauseHolding,
|
|
1110
|
+
pauseMessage,
|
|
1111
|
+
readHookConfig,
|
|
1112
|
+
readStdin,
|
|
1113
|
+
realPath,
|
|
1114
|
+
realShell,
|
|
1115
|
+
record,
|
|
1116
|
+
removeGitHooks,
|
|
1117
|
+
removeInterceptors,
|
|
1118
|
+
reportToDaemon,
|
|
1119
|
+
resolveReal,
|
|
1120
|
+
ruleOnCommand,
|
|
1121
|
+
shellInvocation,
|
|
1122
|
+
verdictFor
|
|
1123
|
+
};
|