@sweny-ai/core 0.1.144 → 0.1.146
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/dist/harness/codex.d.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
* Every sweny opinion in the Codex column of the enforcement matrix is
|
|
22
22
|
* enforced natively here, kept by sweny (watchdog, fencing, env scoping,
|
|
23
|
-
* output
|
|
23
|
+
* output parsing), reported in `degraded`, or refused under strict policy
|
|
24
24
|
* by `policyGate`. Nothing is dropped silently.
|
|
25
25
|
*/
|
|
26
26
|
import type { Logger, McpServerConfig, ToolContext } from "../types.js";
|
package/dist/harness/codex.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*
|
|
21
21
|
* Every sweny opinion in the Codex column of the enforcement matrix is
|
|
22
22
|
* enforced natively here, kept by sweny (watchdog, fencing, env scoping,
|
|
23
|
-
* output
|
|
23
|
+
* output parsing), reported in `degraded`, or refused under strict policy
|
|
24
24
|
* by `policyGate`. Nothing is dropped silently.
|
|
25
25
|
*/
|
|
26
26
|
import { spawn, execFile } from "node:child_process";
|
|
@@ -201,15 +201,21 @@ export function isStrictCompatibleSchema(schema) {
|
|
|
201
201
|
// ─── Process registry (crash cleanup) ────────────────────────────
|
|
202
202
|
const liveChildren = new Set();
|
|
203
203
|
let exitHookInstalled = false;
|
|
204
|
-
/**
|
|
205
|
-
* Stop Codex. It stays in sweny's process group (not detached), so a Ctrl-C
|
|
206
|
-
* or a CI cancel reaches it too; its MCP servers exit when their stdin closes.
|
|
207
|
-
*/
|
|
204
|
+
/** Stop the owned process tree, even after its leader has exited. */
|
|
208
205
|
function stopChild(child, signal) {
|
|
209
|
-
if (child.pid === undefined
|
|
206
|
+
if (child.pid === undefined)
|
|
210
207
|
return;
|
|
211
208
|
try {
|
|
212
|
-
|
|
209
|
+
if (process.platform !== "win32") {
|
|
210
|
+
// exec() gives each run its own process group. Descendants can retain
|
|
211
|
+
// stdout/stderr after the leader exits, so do not gate this on exitCode.
|
|
212
|
+
process.kill(-child.pid, signal);
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
// Windows has no POSIX process groups. Bound the tree-kill utility too.
|
|
216
|
+
execFile("taskkill", ["/pid", String(child.pid), "/T", "/F"], { timeout: 1000 }, () => { });
|
|
217
|
+
child.kill(signal);
|
|
218
|
+
}
|
|
213
219
|
}
|
|
214
220
|
catch {
|
|
215
221
|
// already gone
|
|
@@ -220,10 +226,23 @@ function trackChild(child) {
|
|
|
220
226
|
if (!exitHookInstalled) {
|
|
221
227
|
exitHookInstalled = true;
|
|
222
228
|
// A crashing sweny must not leave Codex (and its MCP servers) running.
|
|
223
|
-
|
|
229
|
+
const cleanup = () => {
|
|
224
230
|
for (const c of liveChildren)
|
|
225
231
|
stopChild(c, "SIGKILL");
|
|
226
|
-
}
|
|
232
|
+
};
|
|
233
|
+
process.on("exit", cleanup);
|
|
234
|
+
// Detached POSIX groups no longer receive our terminal's signals. Clean
|
|
235
|
+
// them up before host once-handlers remove themselves during dispatch.
|
|
236
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
237
|
+
const onSignal = () => {
|
|
238
|
+
cleanup();
|
|
239
|
+
if (process.listenerCount(signal) === 1) {
|
|
240
|
+
process.removeListener(signal, onSignal);
|
|
241
|
+
process.kill(process.pid, signal);
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
process.prependListener(signal, onSignal);
|
|
245
|
+
}
|
|
227
246
|
}
|
|
228
247
|
}
|
|
229
248
|
function mapUsage(u) {
|
|
@@ -470,6 +489,7 @@ export class CodexHarness {
|
|
|
470
489
|
cwd: opts.spawn.cwd,
|
|
471
490
|
env: opts.spawn.env,
|
|
472
491
|
stdio: ["pipe", "pipe", "pipe"],
|
|
492
|
+
detached: process.platform !== "win32",
|
|
473
493
|
});
|
|
474
494
|
}
|
|
475
495
|
catch (err) {
|
|
@@ -478,7 +498,9 @@ export class CodexHarness {
|
|
|
478
498
|
return out;
|
|
479
499
|
}
|
|
480
500
|
trackChild(child);
|
|
501
|
+
let finish;
|
|
481
502
|
const closed = new Promise((resolve) => {
|
|
503
|
+
finish = resolve;
|
|
482
504
|
child.once("close", (code, signal) => {
|
|
483
505
|
out.exitCode = code;
|
|
484
506
|
out.exitSignal = signal;
|
|
@@ -495,9 +517,24 @@ export class CodexHarness {
|
|
|
495
517
|
return;
|
|
496
518
|
stopped = true;
|
|
497
519
|
stopChild(child, "SIGTERM");
|
|
498
|
-
killTimer = setTimeout(() =>
|
|
520
|
+
killTimer = setTimeout(() => {
|
|
521
|
+
stopChild(child, "SIGKILL");
|
|
522
|
+
// An escaped descendant or failed OS kill must not retain this await
|
|
523
|
+
// through inherited pipes after cancellation or leader exit.
|
|
524
|
+
child.stdin?.destroy();
|
|
525
|
+
child.stdout?.destroy();
|
|
526
|
+
child.stderr?.destroy();
|
|
527
|
+
child.unref();
|
|
528
|
+
finish();
|
|
529
|
+
}, this.killGraceMs);
|
|
499
530
|
killTimer.unref?.();
|
|
500
531
|
};
|
|
532
|
+
child.once("exit", (code, signal) => {
|
|
533
|
+
out.exitCode = code;
|
|
534
|
+
out.exitSignal = signal;
|
|
535
|
+
// The leader is done, but its descendants may still own our pipes.
|
|
536
|
+
stop();
|
|
537
|
+
});
|
|
501
538
|
if (abort) {
|
|
502
539
|
const onAbort = () => {
|
|
503
540
|
out.abortReason = abort.reason();
|
|
@@ -649,6 +686,7 @@ export class CodexHarness {
|
|
|
649
686
|
onLine(buf);
|
|
650
687
|
if (killTimer)
|
|
651
688
|
clearTimeout(killTimer);
|
|
689
|
+
stopChild(child, "SIGKILL");
|
|
652
690
|
abort?.clear();
|
|
653
691
|
liveChildren.delete(child);
|
|
654
692
|
return out;
|
|
@@ -677,11 +715,11 @@ export class CodexHarness {
|
|
|
677
715
|
harness: this.info(),
|
|
678
716
|
degraded: [...degraded, ...extra],
|
|
679
717
|
});
|
|
680
|
-
const refused = (why) => {
|
|
718
|
+
const refused = (why, extra = []) => {
|
|
681
719
|
const msg = `codex refused this node: ${why}`;
|
|
682
720
|
this.logger.error(msg);
|
|
683
721
|
// `refused` keeps fail_soft from softening a policy refusal (executor.ts).
|
|
684
|
-
return tag({ status: "failed", data: { error: msg, refused: true }, toolCalls: [] });
|
|
722
|
+
return tag({ status: "failed", data: { error: msg, refused: true }, toolCalls: [] }, extra);
|
|
685
723
|
};
|
|
686
724
|
if (gate.refuse)
|
|
687
725
|
return refused(gate.refuse);
|
|
@@ -799,7 +837,7 @@ export class CodexHarness {
|
|
|
799
837
|
return refused(out.refuse);
|
|
800
838
|
// OpenAI strict structured outputs reject some JSON schemas. When Codex
|
|
801
839
|
// fails on the schema before doing anything, run once more without the
|
|
802
|
-
// native schema
|
|
840
|
+
// native schema in relaxed mode. Prompt-only output is not schema-validated.
|
|
803
841
|
if (schemaArgs.length > 0 &&
|
|
804
842
|
!out.completed &&
|
|
805
843
|
!out.abortReason &&
|
|
@@ -808,7 +846,10 @@ export class CodexHarness {
|
|
|
808
846
|
const why = isStrictCompatibleSchema(req.outputSchema)
|
|
809
847
|
? "codex rejected the output schema"
|
|
810
848
|
: "the output schema is not OpenAI strict-compatible";
|
|
811
|
-
extraDegraded.push(`structured_output: ${why};
|
|
849
|
+
extraDegraded.push(`structured_output: ${why}; native schema validation unavailable`);
|
|
850
|
+
if (policy.strict) {
|
|
851
|
+
return refused("strict policy requires structured_output validation; refusing prompt-only fallback", extraDegraded);
|
|
852
|
+
}
|
|
812
853
|
this.logger.warn(` codex: ${why}; retrying without --output-schema`);
|
|
813
854
|
const again = await execOnce(false);
|
|
814
855
|
if ("refuse" in again)
|
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
* host network except through that proxy.
|
|
20
20
|
* - filesystem: the workspace (cwd) and a scratch HOME are writable; the rest
|
|
21
21
|
* of the filesystem is read-only; the operator's credential files are
|
|
22
|
-
* unreadable.
|
|
22
|
+
* unreadable. Sibling homes under the same configured scratch root are
|
|
23
|
+
* hidden; only this run's HOME is exposed. Dry runs (`readOnly`) leave the
|
|
24
|
+
* workspace read-only too. All concurrent credential-bearing adapters must
|
|
25
|
+
* use the same scratch root; different roots are not mutually isolated.
|
|
23
26
|
* - env: exactly the env the adapter passes (already scoped by agent-env.ts),
|
|
24
27
|
* with HOME, XDG dirs and TMPDIR pointed into the scratch HOME. On Linux the
|
|
25
28
|
* process also gets its own PID namespace, so it cannot read another
|
|
@@ -55,7 +58,7 @@ export interface SandboxWrapRequest extends AgentSpawn {
|
|
|
55
58
|
}
|
|
56
59
|
/** The spawn to run instead, plus the scratch it owns. */
|
|
57
60
|
export interface WrappedSpawn extends AgentSpawn {
|
|
58
|
-
/** Scratch HOME. Adapters
|
|
61
|
+
/** Scratch HOME. Adapters sharing one scratch root may copy scoped auth/config here before spawning. */
|
|
59
62
|
home: string;
|
|
60
63
|
/** Remove the scratch HOME and the sandbox settings. Idempotent. */
|
|
61
64
|
cleanup(): Promise<void>;
|
|
@@ -104,6 +107,8 @@ export declare function buildSrtSettings(req: Pick<SandboxWrapRequest, "cwd" | "
|
|
|
104
107
|
credentialHome: string;
|
|
105
108
|
exists?: (p: string) => boolean;
|
|
106
109
|
credentialPaths?: readonly string[];
|
|
110
|
+
/** Shared wrapper storage to hide, with only `home` re-exposed. */
|
|
111
|
+
isolationRoot?: string;
|
|
107
112
|
}): SrtSettings;
|
|
108
113
|
/** Env for the wrapped process: the adapter's env with HOME, XDG and temp dirs moved into the scratch HOME. */
|
|
109
114
|
export declare function scratchEnv(env: Record<string, string>, home: string): Record<string, string>;
|
|
@@ -114,7 +119,15 @@ export interface SrtWrapperOptions {
|
|
|
114
119
|
credentialHome?: string;
|
|
115
120
|
/** Override {@link CREDENTIAL_PATHS} (relative to `credentialHome`, or absolute). */
|
|
116
121
|
credentialPaths?: readonly string[];
|
|
117
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* Storage parent, default `os.tmpdir()`. Runs share a per-user child under
|
|
124
|
+
* this directory, hidden except for their own HOME. Concurrent adapters
|
|
125
|
+
* carrying credentials must use the same parent; separate roots are not
|
|
126
|
+
* mutually isolated. The existing parent is never removed. Its canonical
|
|
127
|
+
* hierarchy must be owned by this user or root; other-writable ancestors
|
|
128
|
+
* must be sticky (like /tmp). The shared child must be private and owned
|
|
129
|
+
* by this user. Unsafe existing paths are rejected, never repaired.
|
|
130
|
+
*/
|
|
118
131
|
scratchRoot?: string;
|
|
119
132
|
}
|
|
120
133
|
export declare class SrtSandboxWrapper implements SandboxWrapper {
|
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
* host network except through that proxy.
|
|
20
20
|
* - filesystem: the workspace (cwd) and a scratch HOME are writable; the rest
|
|
21
21
|
* of the filesystem is read-only; the operator's credential files are
|
|
22
|
-
* unreadable.
|
|
22
|
+
* unreadable. Sibling homes under the same configured scratch root are
|
|
23
|
+
* hidden; only this run's HOME is exposed. Dry runs (`readOnly`) leave the
|
|
24
|
+
* workspace read-only too. All concurrent credential-bearing adapters must
|
|
25
|
+
* use the same scratch root; different roots are not mutually isolated.
|
|
23
26
|
* - env: exactly the env the adapter passes (already scoped by agent-env.ts),
|
|
24
27
|
* with HOME, XDG dirs and TMPDIR pointed into the scratch HOME. On Linux the
|
|
25
28
|
* process also gets its own PID namespace, so it cannot read another
|
|
@@ -40,7 +43,7 @@
|
|
|
40
43
|
*/
|
|
41
44
|
import { execFile } from "node:child_process";
|
|
42
45
|
import { existsSync, realpathSync } from "node:fs";
|
|
43
|
-
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
46
|
+
import { lstat, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
|
44
47
|
import { homedir, tmpdir } from "node:os";
|
|
45
48
|
import * as path from "node:path";
|
|
46
49
|
import { DEFAULT_SANDBOX_DOMAINS, parseList, resolveSandboxMode } from "../agent-env.js";
|
|
@@ -119,10 +122,14 @@ export function buildSrtSettings(req, opts) {
|
|
|
119
122
|
const denyRead = (opts.credentialPaths ?? CREDENTIAL_PATHS)
|
|
120
123
|
.map((rel) => (path.isAbsolute(rel) ? rel : path.join(opts.credentialHome, rel)))
|
|
121
124
|
.filter((p) => exists(p));
|
|
125
|
+
// Deny the parent, not a snapshot of sibling homes: later-created runs
|
|
126
|
+
// must stay hidden too. srt allowRead carves out only our own HOME.
|
|
127
|
+
if (opts.isolationRoot)
|
|
128
|
+
denyRead.push(opts.isolationRoot);
|
|
122
129
|
const allowWrite = req.readOnly ? [opts.home] : [real(req.cwd), opts.home];
|
|
123
130
|
return {
|
|
124
131
|
network: { allowedDomains, deniedDomains: [], strictAllowlist: true, allowLocalBinding: false },
|
|
125
|
-
filesystem: { denyRead, allowRead: [], allowWrite, denyWrite: [] },
|
|
132
|
+
filesystem: { denyRead, allowRead: opts.isolationRoot ? [opts.home] : [], allowWrite, denyWrite: [] },
|
|
126
133
|
};
|
|
127
134
|
}
|
|
128
135
|
/** Env for the wrapped process: the adapter's env with HOME, XDG and temp dirs moved into the scratch HOME. */
|
|
@@ -147,7 +154,39 @@ export class SrtSandboxWrapper {
|
|
|
147
154
|
this.opts = opts;
|
|
148
155
|
}
|
|
149
156
|
async wrap(req) {
|
|
150
|
-
const
|
|
157
|
+
const uid = process.getuid?.();
|
|
158
|
+
if (uid === undefined)
|
|
159
|
+
throw new Error("Cannot validate sandbox scratch ownership on this platform");
|
|
160
|
+
const storageParent = await realpath(this.opts.scratchRoot ?? tmpdir());
|
|
161
|
+
// A private child can still be renamed by another user through a writable
|
|
162
|
+
// ancestor. Check the canonical hierarchy before accepting any run paths.
|
|
163
|
+
for (let ancestor = storageParent;; ancestor = path.dirname(ancestor)) {
|
|
164
|
+
const info = await lstat(ancestor);
|
|
165
|
+
if (!info.isDirectory() ||
|
|
166
|
+
(info.uid !== uid && info.uid !== 0) ||
|
|
167
|
+
((info.mode & 0o022) !== 0 && (info.mode & 0o1000) === 0)) {
|
|
168
|
+
throw new Error(`Unsafe sandbox scratch ancestor: ${ancestor}`);
|
|
169
|
+
}
|
|
170
|
+
if (ancestor === path.dirname(ancestor))
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
// Keep these components short: srt creates Unix sockets beneath HOME/tmp,
|
|
174
|
+
// and Linux socket paths must fit in 107 bytes (including caller parents).
|
|
175
|
+
const isolationRoot = path.join(storageParent, `sweny-${uid}`);
|
|
176
|
+
try {
|
|
177
|
+
await mkdir(isolationRoot, { mode: 0o700 });
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (error.code !== "EEXIST")
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
// Never adopt a symlink, a foreign-owned directory, or a directory other
|
|
184
|
+
// users can access. Validate newly created paths too; do not chmod them.
|
|
185
|
+
const info = await lstat(isolationRoot);
|
|
186
|
+
if (!info.isDirectory() || info.uid !== uid || (info.mode & 0o077) !== 0) {
|
|
187
|
+
throw new Error(`Unsafe sandbox scratch directory: ${isolationRoot}`);
|
|
188
|
+
}
|
|
189
|
+
const dir = await mkdtemp(path.join(isolationRoot, "r-"));
|
|
151
190
|
let cleaned = false;
|
|
152
191
|
const cleanup = async () => {
|
|
153
192
|
if (cleaned)
|
|
@@ -162,6 +201,7 @@ export class SrtSandboxWrapper {
|
|
|
162
201
|
home,
|
|
163
202
|
credentialHome: this.opts.credentialHome ?? homedir(),
|
|
164
203
|
credentialPaths: this.opts.credentialPaths,
|
|
204
|
+
isolationRoot,
|
|
165
205
|
});
|
|
166
206
|
const settingsPath = path.join(dir, "srt-settings.json");
|
|
167
207
|
await writeFile(settingsPath, JSON.stringify(settings, null, 2), { mode: 0o600 });
|