@sigrun/sigrun 0.1.6 → 0.2.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/CHANGELOG.md +104 -0
- package/README.md +352 -36
- package/dist/blocks/schema.js +9 -2
- package/dist/blocks/validate.js +57 -8
- package/dist/cli.js +26 -2
- package/dist/config.js +67 -1
- package/dist/extension.d.ts +189 -15
- package/dist/harness/ambient.js +39 -185
- package/dist/harness/blocks/entries.js +47 -0
- package/dist/harness/blocks/state.js +58 -0
- package/dist/harness/blocks/tools.js +274 -0
- package/dist/harness/boot.js +253 -20
- package/dist/harness/builtins.js +15 -11
- package/dist/harness/event-bus.js +105 -10
- package/dist/harness/identity/accounts.js +186 -0
- package/dist/harness/identity/groups.js +14 -0
- package/dist/harness/identity/index.js +13 -6
- package/dist/harness/identity/json-store.js +15 -4
- package/dist/harness/identity/setup-code-check.js +9 -0
- package/dist/harness/identity/tokens.js +28 -5
- package/dist/harness/identity/users.js +111 -12
- package/dist/harness/interactions.js +292 -136
- package/dist/harness/loader.js +271 -17
- package/dist/harness/packages.js +1 -1
- package/dist/harness/reserved-sections.js +21 -0
- package/dist/harness/seam-host.js +80 -6
- package/dist/harness/settings.js +63 -2
- package/dist/harness/stores.js +241 -4
- package/dist/harness/user-services.js +391 -0
- package/dist/harness/user-tools.js +187 -0
- package/dist/harness/workspace.js +61 -57
- package/dist/jobs/fire.js +10 -16
- package/dist/sandbox/availability.js +162 -0
- package/dist/sandbox/command-observer.js +59 -0
- package/dist/sandbox/grants.js +159 -0
- package/dist/sandbox/helper.js +29 -0
- package/dist/sandbox/isolation.js +29 -0
- package/dist/sandbox/launch.js +20 -0
- package/dist/sandbox/os-account.js +49 -0
- package/dist/sandbox/policy.js +253 -0
- package/dist/sandbox/pool.js +548 -0
- package/dist/sandbox/protocol.js +167 -0
- package/dist/sandbox/sandboxed-env.js +245 -0
- package/dist/sandbox/unavailable-env.js +49 -0
- package/dist/sandbox/worker.js +281 -0
- package/dist/serve.js +82 -7
- package/dist/server/auth/access-principal.js +16 -0
- package/dist/server/auth/pipeline.js +47 -33
- package/dist/server/blocks/answers.js +94 -0
- package/dist/server/blocks/chat-surfaces.js +393 -0
- package/dist/server/blocks/notes.js +216 -0
- package/dist/server/blocks/summaries.js +328 -0
- package/dist/server/commands.js +5 -0
- package/dist/server/content-parts-lazy.js +13 -0
- package/dist/server/conversation-handler.js +2 -5
- package/dist/server/conversation-ops.js +138 -37
- package/dist/server/helpers.js +18 -6
- package/dist/server/host-bridge.js +40 -20
- package/dist/server/images/cache.js +205 -0
- package/dist/server/images/convert.js +13 -0
- package/dist/server/images/fetch.js +302 -0
- package/dist/server/images/ranges.js +42 -0
- package/dist/server/images/rewrite.js +94 -0
- package/dist/server/images/sign.js +84 -0
- package/dist/server/images/sniff.js +62 -0
- package/dist/server/index.js +144 -4
- package/dist/server/login/flows.js +126 -0
- package/dist/server/login/http.js +31 -0
- package/dist/server/login/resolve.js +133 -0
- package/dist/server/login/setup-claim.js +55 -0
- package/dist/server/login/usernames.js +15 -0
- package/dist/server/ownership.js +13 -0
- package/dist/server/routes/admin.js +58 -0
- package/dist/server/routes/auth.js +88 -2
- package/dist/server/routes/conversations.js +3 -6
- package/dist/server/routes/events.js +28 -3
- package/dist/server/routes/extensions.js +37 -5
- package/dist/server/routes/images.js +267 -0
- package/dist/server/routes/interactions.js +57 -68
- package/dist/server/routes/jobs.js +2 -8
- package/dist/server/routes/login.js +223 -0
- package/dist/server/routes/me.js +90 -0
- package/dist/server/routes/meta.js +15 -0
- package/dist/server/routes/onboarding.js +94 -84
- package/dist/server/routes/settings-sections.js +190 -0
- package/dist/server/routes/settings.js +16 -4
- package/dist/server/routes/submissions.js +1 -1
- package/dist/server/routes/surfaces.js +175 -23
- package/dist/server/routes/users.js +116 -20
- package/dist/server/schema-fixture.js +213 -0
- package/dist/server/session-meta.js +3 -0
- package/dist/server/shutdown.js +11 -4
- package/dist/server/static.js +18 -0
- package/dist/server/surfaces/dedup.js +9 -2
- package/dist/server/surfaces/service.js +181 -13
- package/dist/server/surfaces/settings-surfaces.js +597 -0
- package/dist/server/turns/submit-turn.js +26 -0
- package/dist/server/views.js +16 -0
- package/dist/settings/core-sections.js +532 -0
- package/dist/settings/pending.js +181 -0
- package/dist/settings/registry.js +453 -0
- package/dist/skills/index.js +6 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +6 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +4 -2
- package/node_modules/@sigrun/shared/dist/access.js +11 -4
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +17 -74
- package/node_modules/@sigrun/shared/dist/ambient.js +8 -6
- package/node_modules/@sigrun/shared/dist/blocks/text.js +16 -2
- package/node_modules/@sigrun/shared/dist/blocks/transport.d.ts +4 -0
- package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +21 -4
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +22 -48
- package/node_modules/@sigrun/shared/dist/interactions.js +9 -7
- package/node_modules/@sigrun/shared/dist/settings.d.ts +27 -2
- package/node_modules/@sigrun/shared/dist/themes.d.ts +2 -3
- package/node_modules/@sigrun/shared/dist/themes.js +2 -3
- package/node_modules/@sigrun/shared/dist/turns.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/turns.js +53 -3
- package/node_modules/@sigrun/shared/dist/views.d.ts +175 -12
- package/node_modules/@sigrun/shared/dist/views.js +16 -0
- package/node_modules/@sigrun/shared/package.json +1 -1
- package/package.json +11 -6
- package/webui/dist/assets/index-CxB969qp.js +55 -0
- package/webui/dist/assets/index-yqkL6BSS.css +1 -0
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-CiYInlLx.css +0 -1
- package/webui/dist/assets/index-JDfOQV9z.js +0 -52
|
@@ -1,74 +1,78 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Static workspace resolution (spec §4.5).
|
|
3
|
-
*
|
|
4
|
-
* pi-durable calls `HarnessOptions.env` per tool call to build the
|
|
5
|
-
* conversation's `ExecutionEnv`. sigrun routes that through
|
|
6
|
-
* `workspaceService.forConversation(conversation, prefs)`, a thin wrapper
|
|
7
|
-
* whose signature survives a swap from static config to a dynamic
|
|
8
|
-
* resolver: v1 answers from the static local workspace path carried in
|
|
9
|
-
* `prefs`; a future resolver replaces the *implementation*
|
|
10
|
-
* and nothing downstream changes.
|
|
11
|
-
*
|
|
12
|
-
* v1 is deliberately static: every conversation resolves to the same local
|
|
13
|
-
* workspace. Per-conversation `agent.cwd` (the committed `pi.agent`
|
|
14
|
-
* document's directory within the workspace) is applied by the `toHarnessEnv`
|
|
15
|
-
* adapter below: pi-durable passes it through `EnvTarget.cwd` and applies
|
|
16
|
-
* nothing itself, so the adapter must root the env at it.
|
|
17
|
-
*/
|
|
18
|
-
import { AgentExecutionEnv } from "./agent-env.js";
|
|
1
|
+
/** Resolve each conversation's ExecutionEnv: single-user, jailed, or fail-closed. */
|
|
19
2
|
import { isAbsolute, resolve } from "node:path";
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
* local environment sees the same files), and its `Shell` runs commands
|
|
30
|
-
* rooted at the workspace `cwd`. A fresh instance per call matches how
|
|
31
|
-
* upstream builds envs; equal ids see the same files whatever their `cwd`.
|
|
32
|
-
*/
|
|
33
|
-
export function createWorkspaceService() {
|
|
3
|
+
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
4
|
+
import { buildUserPolicy } from "../sandbox/policy.js";
|
|
5
|
+
import { SandboxedExecutionEnv } from "../sandbox/sandboxed-env.js";
|
|
6
|
+
import { UnavailableExecutionEnv } from "../sandbox/unavailable-env.js";
|
|
7
|
+
import { selfPackageRoot } from "./packages.js";
|
|
8
|
+
import { defaultModuleTrees } from "../sandbox/policy.js";
|
|
9
|
+
import { AgentExecutionEnv } from "./agent-env.js";
|
|
10
|
+
import { lookupOsAccount } from "../sandbox/os-account.js";
|
|
11
|
+
export function createWorkspaceService(deps) {
|
|
34
12
|
return {
|
|
35
|
-
async forConversation(
|
|
13
|
+
async forConversation(conversation, prefs, read, ctx) {
|
|
36
14
|
const workspace = prefs.defaultWorkspace;
|
|
37
15
|
if (workspace.type !== "local") {
|
|
38
|
-
// Fail loud: v1 knows only the static local workspace; any other
|
|
39
|
-
// type reaching here means the preferences are not v1's.
|
|
40
16
|
throw new Error(`Unsupported workspace type: ${JSON.stringify(workspace.type)}`);
|
|
41
17
|
}
|
|
42
|
-
|
|
18
|
+
if (deps === undefined)
|
|
19
|
+
return new AgentExecutionEnv({ cwd: workspace.path });
|
|
20
|
+
const { directory, isolation } = deps;
|
|
21
|
+
const records = directory.list();
|
|
22
|
+
const owner = read === undefined
|
|
23
|
+
? undefined
|
|
24
|
+
: await deps.ownerOf(conversation.id, read, ctx ?? BACKGROUND_CONTEXT);
|
|
25
|
+
const record = records.find((candidate) => candidate.user === owner)
|
|
26
|
+
?? records.find((candidate) => candidate.admin);
|
|
27
|
+
let osAccount;
|
|
28
|
+
if (record?.osUser !== undefined) {
|
|
29
|
+
if (deps.osUsers?.available !== true) {
|
|
30
|
+
return new UnavailableExecutionEnv("osUser requires CAP_SETUID");
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
osAccount = (deps.lookupAccount ?? lookupOsAccount)(record.osUser);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return new UnavailableExecutionEnv(`OS account not found: ${record.osUser}`);
|
|
37
|
+
}
|
|
38
|
+
if (osAccount === undefined) {
|
|
39
|
+
return new UnavailableExecutionEnv(`OS account not found: ${record.osUser}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const active = isolation.active();
|
|
43
|
+
if (osAccount === undefined) {
|
|
44
|
+
if (records.length <= 1 && !active) {
|
|
45
|
+
return new AgentExecutionEnv({ cwd: workspace.path });
|
|
46
|
+
}
|
|
47
|
+
if (!active) {
|
|
48
|
+
return new UnavailableExecutionEnv(isolation.reason() ?? "isolation is not active");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (record === undefined)
|
|
52
|
+
return new UnavailableExecutionEnv("conversation owner is not in the user directory");
|
|
53
|
+
const coreRoot = selfPackageRoot();
|
|
54
|
+
if (coreRoot === undefined)
|
|
55
|
+
return new UnavailableExecutionEnv("sigrun package root not found");
|
|
56
|
+
const policy = buildUserPolicy(record, {
|
|
57
|
+
managed: deps.managed,
|
|
58
|
+
nodeBinDir: process.execPath,
|
|
59
|
+
moduleTrees: defaultModuleTrees(coreRoot),
|
|
60
|
+
...(osAccount === undefined ? {} : { osAccount }),
|
|
61
|
+
});
|
|
62
|
+
if (!policy.ok)
|
|
63
|
+
return new UnavailableExecutionEnv(policy.reason);
|
|
64
|
+
return new SandboxedExecutionEnv(deps.pool.client(policy.policy), policy.policy.workspace);
|
|
43
65
|
},
|
|
44
66
|
};
|
|
45
67
|
}
|
|
46
|
-
/**
|
|
47
|
-
* The default workspace preferences for a configured local workspace path:
|
|
48
|
-
* exactly the spec §4.5 static shape.
|
|
49
|
-
*/
|
|
50
68
|
export function defaultWorkspacePrefs(workspacePath) {
|
|
51
69
|
return { defaultWorkspace: { type: "local", path: workspacePath } };
|
|
52
70
|
}
|
|
53
|
-
/**
|
|
54
|
-
* Adapt the service to the `HarnessOptions.env` builder pi-durable invokes
|
|
55
|
-
* per tool call. pi-durable passes the conversation's committed `agent.cwd`
|
|
56
|
-
* through `EnvTarget.cwd` and applies nothing itself (verified:
|
|
57
|
-
* dist/harness/harness.js:137-138) — this adapter roots the resolved env
|
|
58
|
-
* there, so tools see the conversation's directory. `agent.cwd` is a
|
|
59
|
-
* directory within the environment's file system: an absolute path is
|
|
60
|
-
* assigned as-is, a relative one is joined to the env's base (the workspace
|
|
61
|
-
* root the service resolved), because upstream's Node env would otherwise
|
|
62
|
-
* anchor a bare relative `cwd` at the process directory. Without a cwd, the
|
|
63
|
-
* env stays at the workspace root.
|
|
64
|
-
*/
|
|
65
71
|
export function toHarnessEnv(service, prefs) {
|
|
66
|
-
return async (target,
|
|
67
|
-
const env = await service.forConversation({ id: target.conversationId }, prefs);
|
|
72
|
+
return async (target, context) => {
|
|
73
|
+
const env = await service.forConversation({ id: target.conversationId }, prefs, target.read, context);
|
|
68
74
|
if (target.cwd != null) {
|
|
69
|
-
env.cwd = isAbsolute(target.cwd)
|
|
70
|
-
? target.cwd
|
|
71
|
-
: resolve(env.cwd, target.cwd);
|
|
75
|
+
env.cwd = isAbsolute(target.cwd) ? target.cwd : resolve(env.cwd, target.cwd);
|
|
72
76
|
}
|
|
73
77
|
return env;
|
|
74
78
|
};
|
package/dist/jobs/fire.js
CHANGED
|
@@ -38,6 +38,7 @@ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
|
38
38
|
import { parseModelSpec } from "../config.js";
|
|
39
39
|
import { expandInput } from "../commands/expand.js";
|
|
40
40
|
import { SessionMetaDoc } from "../server/session-meta.js";
|
|
41
|
+
import { noteThen } from "../server/turns/submit-turn.js";
|
|
41
42
|
import { JobRunDoc, patchJob, readJob } from "./docs.js";
|
|
42
43
|
/**
|
|
43
44
|
* Fire one job under `fireKey`. `fireAt` is the epoch ms the key pins
|
|
@@ -173,16 +174,12 @@ async function firePrompt(deps, id, record, fireKey, baseRun, prior, context) {
|
|
|
173
174
|
draft.lastRun = run;
|
|
174
175
|
}, context);
|
|
175
176
|
}
|
|
176
|
-
await
|
|
177
|
+
const prompt = await jobPromptText(deps, conversationId, action.prompt, context);
|
|
178
|
+
await noteThen(deps.harness, conversation, () => conversation.submit({
|
|
177
179
|
type: "input",
|
|
178
|
-
content: [
|
|
179
|
-
{
|
|
180
|
-
type: "text",
|
|
181
|
-
text: await jobPromptText(deps, conversationId, action.prompt, context),
|
|
182
|
-
},
|
|
183
|
-
],
|
|
180
|
+
content: [{ type: "text", text: prompt }],
|
|
184
181
|
requestId: `job:${fireKey}`,
|
|
185
|
-
}, context);
|
|
182
|
+
}, context), context);
|
|
186
183
|
return { outcome: "running", conversationId };
|
|
187
184
|
}
|
|
188
185
|
// conversation: "new" — the run record + conversation in ONE commit.
|
|
@@ -221,6 +218,7 @@ async function firePrompt(deps, id, record, fireKey, baseRun, prior, context) {
|
|
|
221
218
|
meta.deliver = [...action.deliver];
|
|
222
219
|
if (record.owner !== undefined)
|
|
223
220
|
meta.owner = record.owner;
|
|
221
|
+
await deps.rewriteUserSelection?.(tx, newId, undefined, record.owner);
|
|
224
222
|
if (action.runSharing !== undefined)
|
|
225
223
|
meta.sharing = action.runSharing;
|
|
226
224
|
meta.name = runName(record.spec.name, deps.now(), record, deps);
|
|
@@ -238,16 +236,12 @@ async function firePrompt(deps, id, record, fireKey, baseRun, prior, context) {
|
|
|
238
236
|
// fire (never silently succeed).
|
|
239
237
|
return { outcome: "blocked" };
|
|
240
238
|
}
|
|
241
|
-
await
|
|
239
|
+
const prompt = await jobPromptText(deps, conversationId, action.prompt, context);
|
|
240
|
+
await noteThen(deps.harness, conversation, () => conversation.submit({
|
|
242
241
|
type: "input",
|
|
243
|
-
content: [
|
|
244
|
-
{
|
|
245
|
-
type: "text",
|
|
246
|
-
text: await jobPromptText(deps, conversationId, action.prompt, context),
|
|
247
|
-
},
|
|
248
|
-
],
|
|
242
|
+
content: [{ type: "text", text: prompt }],
|
|
249
243
|
requestId: `job:${fireKey}`,
|
|
250
|
-
}, context);
|
|
244
|
+
}, context), context);
|
|
251
245
|
return { outcome: "running", conversationId };
|
|
252
246
|
}
|
|
253
247
|
/** `<job name> · <YYYY-MM-DD HH:mm in the job's timezone>` (the plan's name). */
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/** Boot-time Landlock availability probe and fail-closed helper self-test. */
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { lookupOsAccount } from "./os-account.js";
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
/**
|
|
10
|
+
* Prove a real identity transition, not merely the presence of a helper.
|
|
11
|
+
* Every failure has the one operator-actionable reason; no failed switch is
|
|
12
|
+
* ever treated as permission to run under the service account instead.
|
|
13
|
+
*/
|
|
14
|
+
export async function probeOsUsers(helper, options) {
|
|
15
|
+
const unavailable = () => ({
|
|
16
|
+
available: false,
|
|
17
|
+
status: "osUsers requires CAP_SETUID",
|
|
18
|
+
});
|
|
19
|
+
try {
|
|
20
|
+
const account = lookupOsAccount("nobody", options?.read);
|
|
21
|
+
if (account === undefined)
|
|
22
|
+
return unavailable();
|
|
23
|
+
const run = options?.run ?? (async (command, args, execOptions) => execFileAsync(command, args, execOptions));
|
|
24
|
+
const { stdout } = await run(helper, ["run", "--", "/usr/bin/id", "-u"], {
|
|
25
|
+
env: {
|
|
26
|
+
LC_ALL: "C",
|
|
27
|
+
SIGRUN_SANDBOX_POLICY: JSON.stringify({
|
|
28
|
+
ro: ["/usr", "/usr/bin", "/bin", "/sbin", "/lib", "/lib32", "/lib64", "/etc", "/opt", "/proc", "/sys"],
|
|
29
|
+
rw: [],
|
|
30
|
+
minAbi: 6,
|
|
31
|
+
uid: account.uid,
|
|
32
|
+
gid: account.gid,
|
|
33
|
+
groups: account.groups,
|
|
34
|
+
}),
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
return stdout.trim() === String(account.uid)
|
|
38
|
+
? { available: true, status: "available" }
|
|
39
|
+
: unavailable();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return unavailable();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Execute the helper's lightweight `probe` command and parse its ABI. */
|
|
46
|
+
export async function runHelperProbe(helper) {
|
|
47
|
+
const { stdout } = await execFileAsync(helper, ["probe"]);
|
|
48
|
+
const result = JSON.parse(stdout);
|
|
49
|
+
if (typeof result !== "object" ||
|
|
50
|
+
result === null ||
|
|
51
|
+
!("abi" in result) ||
|
|
52
|
+
typeof result.abi !== "number" ||
|
|
53
|
+
!Number.isInteger(result.abi)) {
|
|
54
|
+
throw new Error("sigrun-sandbox probe returned an invalid ABI");
|
|
55
|
+
}
|
|
56
|
+
return { abi: result.abi };
|
|
57
|
+
}
|
|
58
|
+
/** One reader invocation, changing only whether the probe dir is granted. */
|
|
59
|
+
async function runSelfTestLeg(helper, reader, probe, probeDir, grantProbe) {
|
|
60
|
+
const ro = [
|
|
61
|
+
"/bin",
|
|
62
|
+
"/usr/bin",
|
|
63
|
+
"/lib",
|
|
64
|
+
"/lib64",
|
|
65
|
+
"/usr/lib",
|
|
66
|
+
"/usr/lib64",
|
|
67
|
+
dirname(reader),
|
|
68
|
+
dirname(process.execPath),
|
|
69
|
+
...(grantProbe ? [probeDir] : []),
|
|
70
|
+
];
|
|
71
|
+
return execFileAsync(helper, ["run", "--", process.execPath, reader, probe], {
|
|
72
|
+
env: {
|
|
73
|
+
...process.env,
|
|
74
|
+
LC_ALL: "C",
|
|
75
|
+
SIGRUN_SANDBOX_POLICY: JSON.stringify({ ro, rw: [], minAbi: 6 }),
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
const SELF_TEST_READER = `
|
|
80
|
+
console.log("BEGIN");
|
|
81
|
+
try {
|
|
82
|
+
const content = require("node:fs").readFileSync(process.argv[2], "utf8");
|
|
83
|
+
console.log("READ_OK:" + content);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.log("READ_DENIED:" + (error && error.code ? error.code : "UNKNOWN"));
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
}
|
|
88
|
+
`;
|
|
89
|
+
/**
|
|
90
|
+
* The security boundary check runs OUR reader twice with identical argv;
|
|
91
|
+
* only the probe-directory grant changes. The reader prints BEGIN before
|
|
92
|
+
* touching the file, then READ_OK with the content or READ_DENIED with the
|
|
93
|
+
* machine-readable errno. The control requires exact successful output,
|
|
94
|
+
* proving helper exec, node, the script, and the file work when permitted.
|
|
95
|
+
* The denial leg requires BEGIN plus READ_DENIED:EACCES and no helper-refusal
|
|
96
|
+
* line. No locale-dependent stderr text classifies the failure: the errno is
|
|
97
|
+
* the positive evidence, and BEGIN proves the reader actually executed.
|
|
98
|
+
*/
|
|
99
|
+
export async function selfTestHelper(helper) {
|
|
100
|
+
const root = await mkdtemp(join(tmpdir(), "sigrun-sandbox-self-test-"));
|
|
101
|
+
const grantedDir = join(root, "granted");
|
|
102
|
+
const probeDir = join(root, "probe");
|
|
103
|
+
const reader = join(grantedDir, "reader.cjs");
|
|
104
|
+
const probe = join(probeDir, "probe");
|
|
105
|
+
const content = "sandbox boundary probe\n";
|
|
106
|
+
try {
|
|
107
|
+
await mkdir(grantedDir);
|
|
108
|
+
await mkdir(probeDir);
|
|
109
|
+
await writeFile(reader, SELF_TEST_READER, { mode: 0o600 });
|
|
110
|
+
await writeFile(probe, content, { mode: 0o600 });
|
|
111
|
+
let control;
|
|
112
|
+
try {
|
|
113
|
+
control = await runSelfTestLeg(helper, reader, probe, probeDir, true);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
if (control.stdout !== `BEGIN\nREAD_OK:${content}\n`)
|
|
119
|
+
return false;
|
|
120
|
+
try {
|
|
121
|
+
await runSelfTestLeg(helper, reader, probe, probeDir, false);
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
if (typeof error !== "object" || error === null)
|
|
126
|
+
return false;
|
|
127
|
+
const outcome = error;
|
|
128
|
+
if (typeof outcome.stderr !== "string")
|
|
129
|
+
return false;
|
|
130
|
+
return (typeof outcome.code === "number" &&
|
|
131
|
+
outcome.code !== 0 &&
|
|
132
|
+
outcome.stdout === "BEGIN\nREAD_DENIED:EACCES\n" &&
|
|
133
|
+
!outcome.stderr
|
|
134
|
+
.split(/\r?\n/)
|
|
135
|
+
.some((line) => line.startsWith("sigrun-sandbox:")));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
await rm(root, { recursive: true, force: true });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Decide availability once per boot, short-circuiting unusable helpers. */
|
|
143
|
+
export async function probeIsolation(opts) {
|
|
144
|
+
if (opts.platform !== "linux") {
|
|
145
|
+
return { available: false, abi: 0, reason: "not linux" };
|
|
146
|
+
}
|
|
147
|
+
if (opts.helper === undefined) {
|
|
148
|
+
return { available: false, abi: 0, reason: "sandbox helper not found" };
|
|
149
|
+
}
|
|
150
|
+
const { abi } = await opts.runProbe(opts.helper);
|
|
151
|
+
if (abi < 6) {
|
|
152
|
+
return {
|
|
153
|
+
available: false,
|
|
154
|
+
abi,
|
|
155
|
+
reason: `landlock ABI ${abi} < 6`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
if (!(await opts.selfTest(opts.helper))) {
|
|
159
|
+
return { available: false, abi, reason: "self-test failed" };
|
|
160
|
+
}
|
|
161
|
+
return { available: true, abi, helper: opts.helper };
|
|
162
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Conservative ownership for pi-durable's detached command processes. */
|
|
2
|
+
export const UNATTRIBUTED_OWNER = -1;
|
|
3
|
+
/**
|
|
4
|
+
* Install ONE observer per environment's private pid set. Overlapping spawn
|
|
5
|
+
* attribution is intentionally conservative; only the -1 sentinel may remain
|
|
6
|
+
* stale. All ordinary owners are removed at request settlement so PID reuse
|
|
7
|
+
* can never turn an old command entry into a signal for an unrelated group.
|
|
8
|
+
*/
|
|
9
|
+
export function createCommandObserver(opts) {
|
|
10
|
+
const { registry, pending, active, emit } = opts;
|
|
11
|
+
const owners = new Map();
|
|
12
|
+
const originalAdd = registry.add;
|
|
13
|
+
const originalDelete = registry.delete;
|
|
14
|
+
const originalClear = registry.clear;
|
|
15
|
+
registry.add = (pid) => {
|
|
16
|
+
const result = originalAdd.call(registry, pid);
|
|
17
|
+
const currentlyPending = pending();
|
|
18
|
+
const attributed = currentlyPending.size === 0
|
|
19
|
+
? new Set([UNATTRIBUTED_OWNER])
|
|
20
|
+
: new Set(currentlyPending);
|
|
21
|
+
owners.set(pid, attributed);
|
|
22
|
+
for (const id of attributed)
|
|
23
|
+
emit({ id, pid, phase: "start" });
|
|
24
|
+
return result;
|
|
25
|
+
};
|
|
26
|
+
registry.delete = (pid) => {
|
|
27
|
+
const result = originalDelete.call(registry, pid);
|
|
28
|
+
forgetIfSettled(pid);
|
|
29
|
+
return result;
|
|
30
|
+
};
|
|
31
|
+
registry.clear = () => {
|
|
32
|
+
const pids = [...registry];
|
|
33
|
+
originalClear.call(registry);
|
|
34
|
+
for (const pid of pids)
|
|
35
|
+
forgetIfSettled(pid);
|
|
36
|
+
};
|
|
37
|
+
function forgetIfSettled(pid) {
|
|
38
|
+
const attributed = owners.get(pid);
|
|
39
|
+
if (attributed === undefined)
|
|
40
|
+
return;
|
|
41
|
+
for (const id of attributed) {
|
|
42
|
+
// The sentinel can never be proved settled; retain it permanently.
|
|
43
|
+
if (id === UNATTRIBUTED_OWNER || active().has(id))
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
owners.delete(pid);
|
|
47
|
+
for (const id of attributed)
|
|
48
|
+
emit({ id, pid, phase: "end" });
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
owners,
|
|
52
|
+
settleRequest(id) {
|
|
53
|
+
for (const [pid, attributed] of owners) {
|
|
54
|
+
if (attributed.has(id))
|
|
55
|
+
forgetIfSettled(pid);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-user workspace/grant path validation (multi-user M1 §9.5).
|
|
3
|
+
*
|
|
4
|
+
* Every path is made absolute by the caller, resolved with realpath, and
|
|
5
|
+
* only THEN compared in either direction: Landlock follows the resolved
|
|
6
|
+
* hierarchy, so checking the spelling before realpath would let a symlink
|
|
7
|
+
* into a store root through. Missing/unresolvable paths fail closed.
|
|
8
|
+
*
|
|
9
|
+
* CORE-OWNED VS FOREIGN EXTENSION DIRS: the reserved-slug list also names
|
|
10
|
+
* core data dirs (`workspace`, `models`, `users`, …), but those names are
|
|
11
|
+
* NOT extension directories for this validator. `<dataDir>/<name>` counts
|
|
12
|
+
* as an extension directory only when `name` is not RESERVED_EXTENSION_SLUGS
|
|
13
|
+
* and the directory exists. Core dirs have their own explicit rules:
|
|
14
|
+
* `<dataDir>/users` is protected by the ancestor/equality check, and
|
|
15
|
+
* `<dataDir>/users/<other>` by the other-user-area check. In particular,
|
|
16
|
+
* `<dataDir>/workspace` and `<dataDir>/users/<user>/workspace` are valid.
|
|
17
|
+
* Do not "fix" this by treating every data child as an extension dir.
|
|
18
|
+
*/
|
|
19
|
+
import { readdirSync, realpathSync, statSync } from "node:fs";
|
|
20
|
+
import { isAbsolute, join, resolve, sep } from "node:path";
|
|
21
|
+
import { RESERVED_EXTENSION_SLUGS } from "../harness/reserved-sections.js";
|
|
22
|
+
const RESERVED_SLUG_SET = new Set(RESERVED_EXTENSION_SLUGS);
|
|
23
|
+
/** `child === parent` or `child` is below `parent` (path-segment aware). */
|
|
24
|
+
function inside(child, parent) {
|
|
25
|
+
return child === parent || child.startsWith(parent.endsWith(sep) ? parent : parent + sep);
|
|
26
|
+
}
|
|
27
|
+
function canonical(path) {
|
|
28
|
+
try {
|
|
29
|
+
return realpathSync(path);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return resolve(path);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function directoryExists(path) {
|
|
36
|
+
try {
|
|
37
|
+
return statSync(path).isDirectory();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Existing direct data children not claimed by a reserved core slug. */
|
|
44
|
+
function extensionDirectories(dataDir) {
|
|
45
|
+
try {
|
|
46
|
+
return readdirSync(dataDir, { withFileTypes: true })
|
|
47
|
+
.filter((entry) => (entry.isDirectory() || entry.isSymbolicLink()) &&
|
|
48
|
+
!RESERVED_SLUG_SET.has(entry.name) &&
|
|
49
|
+
directoryExists(join(dataDir, entry.name)))
|
|
50
|
+
.map((entry) => canonical(join(dataDir, entry.name)));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function rejectContaining(candidate, path, label) {
|
|
57
|
+
if (inside(path, candidate)) {
|
|
58
|
+
return { ok: false, reason: `path is or contains ${label}` };
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
function rejectInside(candidate, path, label) {
|
|
63
|
+
if (inside(candidate, path)) {
|
|
64
|
+
return { ok: false, reason: `path is inside ${label}` };
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Validate one workspace or grant. The returned realpath is the only value
|
|
70
|
+
* callers may store or use to construct a policy.
|
|
71
|
+
*/
|
|
72
|
+
export function validateGrantPath(path, ctx) {
|
|
73
|
+
if (!isAbsolute(path))
|
|
74
|
+
return { ok: false, reason: "path must be absolute" };
|
|
75
|
+
let candidate;
|
|
76
|
+
try {
|
|
77
|
+
candidate = realpathSync(path);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return { ok: false, reason: "path does not exist or cannot be resolved" };
|
|
81
|
+
}
|
|
82
|
+
const { managed, user, inheritedFrom } = ctx;
|
|
83
|
+
const { roots } = managed;
|
|
84
|
+
const rootLabels = [
|
|
85
|
+
[roots.configDir, "configDir"],
|
|
86
|
+
[roots.dataDir, "dataDir"],
|
|
87
|
+
[roots.cacheDir, "cacheDir"],
|
|
88
|
+
[roots.stateDir, "stateDir"],
|
|
89
|
+
[managed.storagePath, "storagePath"],
|
|
90
|
+
[join(roots.dataDir, "users"), "dataDir/users"],
|
|
91
|
+
[managed.coreRoot, "coreRoot"],
|
|
92
|
+
];
|
|
93
|
+
for (const [protectedPath, label] of rootLabels) {
|
|
94
|
+
const rejected = rejectContaining(candidate, canonical(protectedPath), label);
|
|
95
|
+
if (rejected !== undefined)
|
|
96
|
+
return rejected;
|
|
97
|
+
}
|
|
98
|
+
const extensions = extensionDirectories(roots.dataDir);
|
|
99
|
+
for (const extension of extensions) {
|
|
100
|
+
const rejected = rejectContaining(candidate, extension, `extension directory ${extension}`);
|
|
101
|
+
if (rejected !== undefined)
|
|
102
|
+
return rejected;
|
|
103
|
+
}
|
|
104
|
+
for (const [protectedPath, label] of [
|
|
105
|
+
[roots.configDir, "configDir"],
|
|
106
|
+
[roots.stateDir, "stateDir"],
|
|
107
|
+
[roots.cacheDir, "cacheDir"],
|
|
108
|
+
[managed.storagePath, "storagePath"],
|
|
109
|
+
[managed.coreRoot, "coreRoot"],
|
|
110
|
+
]) {
|
|
111
|
+
const rejected = rejectInside(candidate, canonical(protectedPath), label);
|
|
112
|
+
if (rejected !== undefined)
|
|
113
|
+
return rejected;
|
|
114
|
+
}
|
|
115
|
+
for (const extension of extensions) {
|
|
116
|
+
const rejected = rejectInside(candidate, extension, `extension directory ${extension}`);
|
|
117
|
+
if (rejected !== undefined)
|
|
118
|
+
return rejected;
|
|
119
|
+
}
|
|
120
|
+
const usersRoot = canonical(join(roots.dataDir, "users"));
|
|
121
|
+
if (inside(candidate, usersRoot)) {
|
|
122
|
+
// Identity is the LEXICAL path segment under users/, never whatever
|
|
123
|
+
// that segment resolves to. Canonicalizing the own area would let a
|
|
124
|
+
// users/alice symlink into Bob's area make both sides agree on Bob's
|
|
125
|
+
// realpath, turning the exception into a cross-user bypass.
|
|
126
|
+
const ownArea = join(usersRoot, user);
|
|
127
|
+
const inheritedArea = inheritedFrom === undefined || inheritedFrom === user
|
|
128
|
+
? undefined
|
|
129
|
+
: join(usersRoot, inheritedFrom);
|
|
130
|
+
if (!inside(candidate, ownArea) &&
|
|
131
|
+
(inheritedArea === undefined || !inside(candidate, inheritedArea))) {
|
|
132
|
+
return { ok: false, reason: "path is inside another user's area" };
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { ok: true, realpath: candidate };
|
|
136
|
+
}
|
|
137
|
+
/** Re-run the current validation over every stored path for this user. */
|
|
138
|
+
export function revalidate(record, managed) {
|
|
139
|
+
if (record.workspace !== undefined) {
|
|
140
|
+
const result = validateGrantPath(record.workspace, {
|
|
141
|
+
managed,
|
|
142
|
+
user: record.user,
|
|
143
|
+
});
|
|
144
|
+
if (!result.ok)
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
for (const grant of record.grants ?? []) {
|
|
148
|
+
const result = validateGrantPath(grant.path, {
|
|
149
|
+
managed,
|
|
150
|
+
user: record.user,
|
|
151
|
+
...(grant.inheritedFrom === undefined
|
|
152
|
+
? {}
|
|
153
|
+
: { inheritedFrom: grant.inheritedFrom }),
|
|
154
|
+
});
|
|
155
|
+
if (!result.ok)
|
|
156
|
+
return result;
|
|
157
|
+
}
|
|
158
|
+
return { ok: true };
|
|
159
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Locate the native sandbox helper without probing it. */
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { isAbsolute, join } from "node:path";
|
|
5
|
+
/**
|
|
6
|
+
* Resolve in deployment precedence order: an explicit absolute env path,
|
|
7
|
+
* the installed platform package, then this checkout's release build.
|
|
8
|
+
* An unusable explicit path warns and falls through rather than aborting boot.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveHelper(env, coreRoot) {
|
|
11
|
+
const configured = env.SIGRUN_SANDBOX_HELPER;
|
|
12
|
+
if (configured !== undefined && configured !== "") {
|
|
13
|
+
if (isAbsolute(configured) && existsSync(configured))
|
|
14
|
+
return configured;
|
|
15
|
+
console.warn(`SIGRUN_SANDBOX_HELPER is not an existing absolute path: ${configured}; falling back to installed or repo helper`);
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const platformPackage = `@sigrun/sandbox-linux-${process.arch}`;
|
|
19
|
+
const require = createRequire(join(coreRoot, "package.json"));
|
|
20
|
+
const helper = require.resolve(`${platformPackage}/bin/sigrun-sandbox`);
|
|
21
|
+
if (existsSync(helper))
|
|
22
|
+
return helper;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// The optional platform package is absent in a source-only install.
|
|
26
|
+
}
|
|
27
|
+
const repoHelper = join(coreRoot, "sandbox", "target", "release", "sigrun-sandbox");
|
|
28
|
+
return existsSync(repoHelper) ? repoHelper : undefined;
|
|
29
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** The process-lifetime isolation decision, including auto's irreversible latch. */
|
|
2
|
+
export class IsolationState {
|
|
3
|
+
opts;
|
|
4
|
+
current;
|
|
5
|
+
latched = false;
|
|
6
|
+
constructor(opts) {
|
|
7
|
+
this.opts = opts;
|
|
8
|
+
}
|
|
9
|
+
active() {
|
|
10
|
+
if (this.opts.mode === "auto" && this.opts.availability.available && this.opts.userCount() > 1) {
|
|
11
|
+
this.latched = true;
|
|
12
|
+
}
|
|
13
|
+
const active = this.opts.mode === "off"
|
|
14
|
+
? false
|
|
15
|
+
: this.opts.mode === "always"
|
|
16
|
+
? this.opts.availability.available
|
|
17
|
+
: this.latched;
|
|
18
|
+
if (active !== this.current) {
|
|
19
|
+
this.current = active;
|
|
20
|
+
this.opts.log(`sandbox isolation: ${active ? "active" : "inactive"} (mode ${this.opts.mode}${this.opts.availability.available ? "" : `, unavailable: ${this.reason() ?? "unknown reason"}`})`);
|
|
21
|
+
}
|
|
22
|
+
return active;
|
|
23
|
+
}
|
|
24
|
+
reason() {
|
|
25
|
+
if (this.opts.mode === "off")
|
|
26
|
+
return "isolation mode is off";
|
|
27
|
+
return this.opts.availability.reason;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The shared helper launch shape for worker and per-user service processes. */
|
|
2
|
+
/** Apply a complete policy in the helper, then exec the requested sibling process. */
|
|
3
|
+
export function launchInJail(helper, policy, command, args, env) {
|
|
4
|
+
return {
|
|
5
|
+
command: helper,
|
|
6
|
+
args: ["run", "--", command, ...args],
|
|
7
|
+
env: {
|
|
8
|
+
...env,
|
|
9
|
+
SIGRUN_SANDBOX_POLICY: JSON.stringify({
|
|
10
|
+
ro: policy.ro,
|
|
11
|
+
rw: policy.rw,
|
|
12
|
+
ensureDirs: policy.ensureDirs,
|
|
13
|
+
minAbi: 6,
|
|
14
|
+
...(policy.uid === undefined ? {} : { uid: policy.uid }),
|
|
15
|
+
...(policy.gid === undefined ? {} : { gid: policy.gid }),
|
|
16
|
+
...(policy.groups === undefined ? {} : { groups: policy.groups }),
|
|
17
|
+
}),
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|