@personaxis/core 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/agent.d.ts +105 -0
- package/dist/agent.js +429 -0
- package/dist/appraisal.d.ts +196 -0
- package/dist/appraisal.js +159 -0
- package/dist/approval.d.ts +42 -0
- package/dist/approval.js +71 -0
- package/dist/blackboard.d.ts +86 -0
- package/dist/blackboard.js +139 -0
- package/dist/compile/assemble.d.ts +44 -0
- package/dist/compile/assemble.js +338 -0
- package/dist/compile/dist.d.ts +30 -0
- package/dist/compile/dist.js +66 -0
- package/dist/compile/faithfulness.d.ts +47 -0
- package/dist/compile/faithfulness.js +112 -0
- package/dist/compile/index.d.ts +10 -0
- package/dist/compile/index.js +10 -0
- package/dist/compile/targets.d.ts +62 -0
- package/dist/compile/targets.js +114 -0
- package/dist/config-layers.d.ts +42 -0
- package/dist/config-layers.js +59 -0
- package/dist/config-scan.d.ts +30 -0
- package/dist/config-scan.js +118 -0
- package/dist/context.d.ts +67 -0
- package/dist/context.js +178 -0
- package/dist/envelopes.d.ts +48 -0
- package/dist/envelopes.js +131 -0
- package/dist/events.d.ts +135 -0
- package/dist/events.js +19 -0
- package/dist/evolution-view.d.ts +43 -0
- package/dist/evolution-view.js +67 -0
- package/dist/generated/version.d.ts +1 -0
- package/dist/generated/version.js +2 -0
- package/dist/governance.d.ts +108 -0
- package/dist/governance.js +204 -0
- package/dist/heuristic-appraiser.d.ts +13 -0
- package/dist/heuristic-appraiser.js +48 -0
- package/dist/hooks.d.ts +52 -0
- package/dist/hooks.js +122 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +53 -0
- package/dist/injection.d.ts +48 -0
- package/dist/injection.js +141 -0
- package/dist/live-sync.d.ts +60 -0
- package/dist/live-sync.js +85 -0
- package/dist/llm-appraiser.d.ts +31 -0
- package/dist/llm-appraiser.js +143 -0
- package/dist/lock.d.ts +26 -0
- package/dist/lock.js +99 -0
- package/dist/loop.d.ts +52 -0
- package/dist/loop.js +253 -0
- package/dist/memory-kinds.d.ts +67 -0
- package/dist/memory-kinds.js +106 -0
- package/dist/memory.d.ts +123 -0
- package/dist/memory.js +228 -0
- package/dist/model-config.d.ts +60 -0
- package/dist/model-config.js +102 -0
- package/dist/persona-theme.d.ts +57 -0
- package/dist/persona-theme.js +183 -0
- package/dist/persona.d.ts +88 -0
- package/dist/persona.js +93 -0
- package/dist/ports/index.d.ts +81 -0
- package/dist/ports/index.js +50 -0
- package/dist/provenance.d.ts +63 -0
- package/dist/provenance.js +132 -0
- package/dist/recompile-marker.d.ts +19 -0
- package/dist/recompile-marker.js +36 -0
- package/dist/registry.d.ts +99 -0
- package/dist/registry.js +168 -0
- package/dist/responder.d.ts +47 -0
- package/dist/responder.js +78 -0
- package/dist/sandbox.d.ts +120 -0
- package/dist/sandbox.js +265 -0
- package/dist/self-evolution.d.ts +148 -0
- package/dist/self-evolution.js +364 -0
- package/dist/session-writer.d.ts +52 -0
- package/dist/session-writer.js +104 -0
- package/dist/sessions.d.ts +96 -0
- package/dist/sessions.js +179 -0
- package/dist/sigil.d.ts +41 -0
- package/dist/sigil.js +92 -0
- package/dist/skill-lifecycle.d.ts +74 -0
- package/dist/skill-lifecycle.js +126 -0
- package/dist/skill-review.d.ts +25 -0
- package/dist/skill-review.js +67 -0
- package/dist/spec-edit.d.ts +30 -0
- package/dist/spec-edit.js +137 -0
- package/dist/state-engine.d.ts +40 -0
- package/dist/state-engine.js +50 -0
- package/dist/state-rebuild.d.ts +48 -0
- package/dist/state-rebuild.js +60 -0
- package/dist/sync.d.ts +32 -0
- package/dist/sync.js +95 -0
- package/dist/tool-calling.d.ts +59 -0
- package/dist/tool-calling.js +168 -0
- package/dist/tool-repair.d.ts +20 -0
- package/dist/tool-repair.js +119 -0
- package/dist/tools/exec.d.ts +46 -0
- package/dist/tools/exec.js +132 -0
- package/dist/tools/registry.d.ts +38 -0
- package/dist/tools/registry.js +162 -0
- package/dist/trace.d.ts +63 -0
- package/dist/trace.js +162 -0
- package/dist/verification.d.ts +65 -0
- package/dist/verification.js +190 -0
- package/package.json +38 -0
package/dist/registry.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overseer registry + global/overlay persona model (F7 — plan/08-persona-model).
|
|
3
|
+
*
|
|
4
|
+
* The "master" personaxis-system is a governed *runtime* that is aware of every
|
|
5
|
+
* persona and project in the environment. It is NOT a feeling persona — it
|
|
6
|
+
* orchestrates and audits. This module is its memory:
|
|
7
|
+
*
|
|
8
|
+
* ~/.personaxis/ (override with PERSONAXIS_HOME)
|
|
9
|
+
* registry.json personas, projects, collections, machines
|
|
10
|
+
* personas/<slug>/ global identity + memory (reused across projects)
|
|
11
|
+
*
|
|
12
|
+
* Reuse model: a persona lives globally; each project "mounts" it with a local
|
|
13
|
+
* overlay (its own state.json + project memory). So the same persona can be
|
|
14
|
+
* reused WITH accumulated memory, or instantiated fresh per project. Collections
|
|
15
|
+
* (teams) group personas + projects. State is tracked per machine so the same
|
|
16
|
+
* user-clone can live on Windows/Linux/macOS and reconcile via git without one
|
|
17
|
+
* machine clobbering another.
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
21
|
+
import { homedir, hostname, platform, userInfo } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
export function personaxisHome() {
|
|
24
|
+
return process.env.PERSONAXIS_HOME ?? join(homedir(), ".personaxis");
|
|
25
|
+
}
|
|
26
|
+
/** A stable per-machine id so cross-OS instances reconcile without clobbering. */
|
|
27
|
+
export function machineId() {
|
|
28
|
+
return createHash("sha256")
|
|
29
|
+
.update(`${hostname()}|${platform()}|${userInfo().username}`)
|
|
30
|
+
.digest("hex")
|
|
31
|
+
.slice(0, 16);
|
|
32
|
+
}
|
|
33
|
+
function registryFile() {
|
|
34
|
+
return join(personaxisHome(), "registry.json");
|
|
35
|
+
}
|
|
36
|
+
function empty() {
|
|
37
|
+
return { version: 1, personas: {}, projects: {}, collections: {}, teams: {}, machines: {} };
|
|
38
|
+
}
|
|
39
|
+
export function loadRegistry() {
|
|
40
|
+
const f = registryFile();
|
|
41
|
+
if (!existsSync(f))
|
|
42
|
+
return empty();
|
|
43
|
+
try {
|
|
44
|
+
return { ...empty(), ...JSON.parse(readFileSync(f, "utf-8")) };
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return empty();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export function saveRegistry(reg) {
|
|
51
|
+
mkdirSync(personaxisHome(), { recursive: true });
|
|
52
|
+
// Atomic write: a concurrent reader (another `personaxis` process on the same registry) must never
|
|
53
|
+
// observe a half-written file — otherwise loadRegistry's parse falls back to empty() and silently
|
|
54
|
+
// drops data. Write to a unique temp file, then rename (atomic on the same filesystem).
|
|
55
|
+
const target = registryFile();
|
|
56
|
+
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
57
|
+
writeFileSync(tmp, JSON.stringify(reg, null, 2) + "\n", "utf-8");
|
|
58
|
+
renameSync(tmp, target);
|
|
59
|
+
}
|
|
60
|
+
export function globalPersonaDir(slug) {
|
|
61
|
+
return join(personaxisHome(), "personas", slug);
|
|
62
|
+
}
|
|
63
|
+
export function registerPersona(slug) {
|
|
64
|
+
const reg = loadRegistry();
|
|
65
|
+
const globalPath = join(globalPersonaDir(slug), "personaxis.md");
|
|
66
|
+
if (!reg.personas[slug]) {
|
|
67
|
+
reg.personas[slug] = { slug, globalPath, createdTs: new Date().toISOString() };
|
|
68
|
+
}
|
|
69
|
+
touchMachine(reg);
|
|
70
|
+
saveRegistry(reg);
|
|
71
|
+
return reg.personas[slug];
|
|
72
|
+
}
|
|
73
|
+
export function registerProject(root, slugs) {
|
|
74
|
+
const reg = loadRegistry();
|
|
75
|
+
const rec = {
|
|
76
|
+
root,
|
|
77
|
+
slugs,
|
|
78
|
+
lastSeen: new Date().toISOString(),
|
|
79
|
+
machine: machineId(),
|
|
80
|
+
};
|
|
81
|
+
reg.projects[root] = rec;
|
|
82
|
+
touchMachine(reg);
|
|
83
|
+
saveRegistry(reg);
|
|
84
|
+
return rec;
|
|
85
|
+
}
|
|
86
|
+
export function createCollection(name) {
|
|
87
|
+
const reg = loadRegistry();
|
|
88
|
+
reg.collections[name] ??= { name, personas: [], projects: [] };
|
|
89
|
+
saveRegistry(reg);
|
|
90
|
+
return reg.collections[name];
|
|
91
|
+
}
|
|
92
|
+
export function addToCollection(name, kind, value) {
|
|
93
|
+
const reg = loadRegistry();
|
|
94
|
+
const c = (reg.collections[name] ??= { name, personas: [], projects: [] });
|
|
95
|
+
const list = kind === "persona" ? c.personas : c.projects;
|
|
96
|
+
if (!list.includes(value))
|
|
97
|
+
list.push(value);
|
|
98
|
+
saveRegistry(reg);
|
|
99
|
+
return c;
|
|
100
|
+
}
|
|
101
|
+
// ─── Teams (operational, with roles + a shared goal) ────────────────────────
|
|
102
|
+
export function createTeam(name, lead) {
|
|
103
|
+
const reg = loadRegistry();
|
|
104
|
+
reg.teams[name] ??= { name, lead, members: lead ? [{ slug: lead, role: "lead" }] : [], createdTs: new Date().toISOString() };
|
|
105
|
+
if (lead)
|
|
106
|
+
reg.teams[name].lead = lead;
|
|
107
|
+
saveRegistry(reg);
|
|
108
|
+
return reg.teams[name];
|
|
109
|
+
}
|
|
110
|
+
export function addTeamMember(name, slug, role) {
|
|
111
|
+
const reg = loadRegistry();
|
|
112
|
+
const t = (reg.teams[name] ??= { name, members: [], createdTs: new Date().toISOString() });
|
|
113
|
+
const existing = t.members.find((m) => m.slug === slug);
|
|
114
|
+
if (existing)
|
|
115
|
+
existing.role = role;
|
|
116
|
+
else
|
|
117
|
+
t.members.push({ slug, role });
|
|
118
|
+
if (role === "lead")
|
|
119
|
+
t.lead = slug;
|
|
120
|
+
saveRegistry(reg);
|
|
121
|
+
return t;
|
|
122
|
+
}
|
|
123
|
+
export function setTeamGoal(name, goal) {
|
|
124
|
+
const reg = loadRegistry();
|
|
125
|
+
const t = (reg.teams[name] ??= { name, members: [], createdTs: new Date().toISOString() });
|
|
126
|
+
t.goal = goal;
|
|
127
|
+
saveRegistry(reg);
|
|
128
|
+
return t;
|
|
129
|
+
}
|
|
130
|
+
export function getTeam(name) {
|
|
131
|
+
return loadRegistry().teams[name];
|
|
132
|
+
}
|
|
133
|
+
function touchMachine(reg) {
|
|
134
|
+
reg.machines[machineId()] = { lastSeen: new Date().toISOString(), os: platform() };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Resolve the effective persona path for a project + slug: a project-local
|
|
138
|
+
* overlay (project `.personaxis/personas/<slug>/personaxis.md` or root
|
|
139
|
+
* `.personaxis/personaxis.md`) takes precedence over the global persona.
|
|
140
|
+
* Returns the first existing path, or the global path as the default target.
|
|
141
|
+
*/
|
|
142
|
+
export function resolveEffectivePersona(projectRoot, slug) {
|
|
143
|
+
const candidates = slug
|
|
144
|
+
? [
|
|
145
|
+
join(projectRoot, ".personaxis", "personas", slug, "personaxis.md"),
|
|
146
|
+
join(globalPersonaDir(slug), "personaxis.md"),
|
|
147
|
+
]
|
|
148
|
+
: [join(projectRoot, ".personaxis", "personaxis.md")];
|
|
149
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
150
|
+
if (existsSync(candidates[i])) {
|
|
151
|
+
return { path: candidates[i], scope: i === 0 && slug ? "project-overlay" : "global" };
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return { path: candidates[candidates.length - 1], scope: "none" };
|
|
155
|
+
}
|
|
156
|
+
/** The overseer's situational summary of the whole environment. */
|
|
157
|
+
export function overseerView() {
|
|
158
|
+
const reg = loadRegistry();
|
|
159
|
+
return {
|
|
160
|
+
machine: machineId(),
|
|
161
|
+
personas: Object.keys(reg.personas).length,
|
|
162
|
+
projects: Object.keys(reg.projects).length,
|
|
163
|
+
collections: Object.keys(reg.collections).length,
|
|
164
|
+
teams: Object.keys(reg.teams ?? {}).length,
|
|
165
|
+
machines: Object.keys(reg.machines).length,
|
|
166
|
+
detail: reg,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Responder — the persona's conversational voice (what makes the REPL playable).
|
|
3
|
+
*
|
|
4
|
+
* The Living Loop governs *how the persona changes*; the Responder governs *what it
|
|
5
|
+
* says back*. They are separate on purpose: the appraiser emits structured evolution
|
|
6
|
+
* signals; the responder emits a natural-language reply, grounded in the compiled
|
|
7
|
+
* identity (PERSONA.md), recent memory, and current mood — and bound by the spec
|
|
8
|
+
* (never claims real feelings).
|
|
9
|
+
*
|
|
10
|
+
* `LlmResponder` talks to any OpenAI-compatible endpoint (Ollama/llama.cpp/hosted).
|
|
11
|
+
* `ReflectiveResponder` is an offline fallback: it doesn't fake a conversation, it
|
|
12
|
+
* gives an honest persona-flavored acknowledgement and points to enabling a model.
|
|
13
|
+
*/
|
|
14
|
+
export interface RespondInput {
|
|
15
|
+
message: string;
|
|
16
|
+
/** Compiled identity (PERSONA.md body) — system-prompt slot #1. */
|
|
17
|
+
personaBody: string;
|
|
18
|
+
/** Recent episodic memory lines for grounding (most recent last). */
|
|
19
|
+
memory: string[];
|
|
20
|
+
/** Current mood/affect values for tone. */
|
|
21
|
+
state: Record<string, number>;
|
|
22
|
+
/** Persona display name. */
|
|
23
|
+
name: string;
|
|
24
|
+
}
|
|
25
|
+
export interface Responder {
|
|
26
|
+
respond(input: RespondInput): Promise<string>;
|
|
27
|
+
}
|
|
28
|
+
export interface LlmResponderConfig {
|
|
29
|
+
endpoint: string;
|
|
30
|
+
model: string;
|
|
31
|
+
apiKey?: string;
|
|
32
|
+
maxTokens?: number;
|
|
33
|
+
fetchImpl?: typeof fetch;
|
|
34
|
+
}
|
|
35
|
+
export declare class LlmResponder implements Responder {
|
|
36
|
+
private readonly cfg;
|
|
37
|
+
constructor(cfg: LlmResponderConfig);
|
|
38
|
+
respond(input: RespondInput): Promise<string>;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Offline fallback. It does NOT pretend to converse — it reflects the persona's
|
|
42
|
+
* current modeled tone honestly and nudges the user to enable a model for real
|
|
43
|
+
* dialogue. Deterministic, dependency-free.
|
|
44
|
+
*/
|
|
45
|
+
export declare class ReflectiveResponder implements Responder {
|
|
46
|
+
respond(input: RespondInput): Promise<string>;
|
|
47
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Responder — the persona's conversational voice (what makes the REPL playable).
|
|
3
|
+
*
|
|
4
|
+
* The Living Loop governs *how the persona changes*; the Responder governs *what it
|
|
5
|
+
* says back*. They are separate on purpose: the appraiser emits structured evolution
|
|
6
|
+
* signals; the responder emits a natural-language reply, grounded in the compiled
|
|
7
|
+
* identity (PERSONA.md), recent memory, and current mood — and bound by the spec
|
|
8
|
+
* (never claims real feelings).
|
|
9
|
+
*
|
|
10
|
+
* `LlmResponder` talks to any OpenAI-compatible endpoint (Ollama/llama.cpp/hosted).
|
|
11
|
+
* `ReflectiveResponder` is an offline fallback: it doesn't fake a conversation, it
|
|
12
|
+
* gives an honest persona-flavored acknowledgement and points to enabling a model.
|
|
13
|
+
*/
|
|
14
|
+
const GUARD = "You are this persona. Speak in its voice, consistent with its identity, values, and current mood. " +
|
|
15
|
+
"You are an AI: never claim real human feelings or consciousness; you may describe your modeled affect as state. " +
|
|
16
|
+
"Be helpful and concise. Do not invent facts about the user.";
|
|
17
|
+
export class LlmResponder {
|
|
18
|
+
cfg;
|
|
19
|
+
constructor(cfg) {
|
|
20
|
+
this.cfg = cfg;
|
|
21
|
+
}
|
|
22
|
+
async respond(input) {
|
|
23
|
+
const fetchImpl = this.cfg.fetchImpl ?? fetch;
|
|
24
|
+
const system = [
|
|
25
|
+
GUARD,
|
|
26
|
+
"",
|
|
27
|
+
"# Identity",
|
|
28
|
+
input.personaBody.slice(0, 6000),
|
|
29
|
+
"",
|
|
30
|
+
"# Current modeled state",
|
|
31
|
+
Object.entries(input.state)
|
|
32
|
+
.map(([k, v]) => `${k}=${v.toFixed(2)}`)
|
|
33
|
+
.join(", "),
|
|
34
|
+
input.memory.length ? "\n# Recent memory\n" + input.memory.slice(-6).join("\n") : "",
|
|
35
|
+
].join("\n");
|
|
36
|
+
const res = await fetchImpl(`${this.cfg.endpoint.replace(/\/$/, "")}/chat/completions`, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: {
|
|
39
|
+
"content-type": "application/json",
|
|
40
|
+
...(this.cfg.apiKey ? { authorization: `Bearer ${this.cfg.apiKey}` } : {}),
|
|
41
|
+
},
|
|
42
|
+
body: JSON.stringify({
|
|
43
|
+
model: this.cfg.model,
|
|
44
|
+
messages: [
|
|
45
|
+
{ role: "system", content: system },
|
|
46
|
+
{ role: "user", content: input.message },
|
|
47
|
+
],
|
|
48
|
+
temperature: 0.7,
|
|
49
|
+
max_tokens: this.cfg.maxTokens ?? 512,
|
|
50
|
+
}),
|
|
51
|
+
});
|
|
52
|
+
if (!res.ok)
|
|
53
|
+
throw new Error(`responder HTTP ${res.status}`);
|
|
54
|
+
let json;
|
|
55
|
+
try {
|
|
56
|
+
json = (await res.json());
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new Error("responder returned a non-JSON body");
|
|
60
|
+
}
|
|
61
|
+
const content = (json.choices?.[0]?.message?.content ?? "").trim();
|
|
62
|
+
return content || "(the model returned an empty reply — try rephrasing, or check the model/endpoint)";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Offline fallback. It does NOT pretend to converse — it reflects the persona's
|
|
67
|
+
* current modeled tone honestly and nudges the user to enable a model for real
|
|
68
|
+
* dialogue. Deterministic, dependency-free.
|
|
69
|
+
*/
|
|
70
|
+
export class ReflectiveResponder {
|
|
71
|
+
async respond(input) {
|
|
72
|
+
const tone = input.state["mood.tone"] ?? 0;
|
|
73
|
+
const mood = tone > 0.12 ? "upbeat" : tone < -0.12 ? "subdued" : "even";
|
|
74
|
+
return (`(${input.name}, modeled tone: ${mood}) I registered that and updated my state + memory. ` +
|
|
75
|
+
`I can't hold a full conversation without a model — set PERSONAXIS_ENDPOINT + PERSONAXIS_MODEL ` +
|
|
76
|
+
`(Ollama/llama.cpp) or BYOK to talk with me for real.`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission & sandbox policy (F3 / T9 — plan/11-security).
|
|
3
|
+
*
|
|
4
|
+
* Honest scope: real *kernel* sandboxing needs native primitives (macOS Seatbelt,
|
|
5
|
+
* Linux Landlock/seccomp/bubblewrap, Windows job objects / restricted tokens).
|
|
6
|
+
* This module provides two things that ARE doable cross-platform and that the big
|
|
7
|
+
* agents rely on most:
|
|
8
|
+
*
|
|
9
|
+
* 1. A two-axis POLICY ENGINE (approval × sandbox, the Codex model) that DECIDES
|
|
10
|
+
* allow | ask | deny for a command — pure, deterministic, fully tested. This
|
|
11
|
+
* is the load-bearing control: a denied command never runs.
|
|
12
|
+
* 2. A best-effort NATIVE WRAPPER that, when the command is allowed, wraps it
|
|
13
|
+
* with the platform's available sandbox (sandbox-exec / bwrap) so writes and
|
|
14
|
+
* network are constrained at the OS level where possible.
|
|
15
|
+
*
|
|
16
|
+
* If no native sandbox is available, enforcement degrades to the policy decision
|
|
17
|
+
* (deny-by-default for risky ops) — never a silent full-access fallback.
|
|
18
|
+
*/
|
|
19
|
+
export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access";
|
|
20
|
+
export type ApprovalMode = "untrusted" | "on-failure" | "on-request" | "never";
|
|
21
|
+
export interface Policy {
|
|
22
|
+
sandbox: SandboxMode;
|
|
23
|
+
approval: ApprovalMode;
|
|
24
|
+
/** Regexes (as strings) that force-allow a matching command. */
|
|
25
|
+
allow: string[];
|
|
26
|
+
/** Regexes that force-deny a matching command (highest precedence). */
|
|
27
|
+
deny: string[];
|
|
28
|
+
workspaceRoot: string;
|
|
29
|
+
/**
|
|
30
|
+
* FR.8: additional roots writable under `workspace-write` (the workspaceRoot
|
|
31
|
+
* is always one). Lets a host grant, e.g., a build output dir outside the repo
|
|
32
|
+
* without escalating to danger-full-access.
|
|
33
|
+
*/
|
|
34
|
+
writableRoots?: string[];
|
|
35
|
+
/**
|
|
36
|
+
* FR.8: per-category approval overrides — finer than the single global
|
|
37
|
+
* `approval` knob (e.g. network commands always ask while plain writes flow).
|
|
38
|
+
*/
|
|
39
|
+
approvals?: Partial<Record<"network" | "destructive" | "write", ApprovalMode>>;
|
|
40
|
+
}
|
|
41
|
+
export declare const DEFAULT_POLICY: Policy;
|
|
42
|
+
/**
|
|
43
|
+
* FR.8 (Codex protocol.rs anti-escalation): subpaths that stay PROTECTED even
|
|
44
|
+
* inside a writable root. `.git/hooks` = arbitrary-code-execution escalation
|
|
45
|
+
* (a write there runs on the user's next git command); `.personaxis` = the
|
|
46
|
+
* persona's identity artifacts — raw file writes would bypass the governance
|
|
47
|
+
* ledger (self-edits are the sanctioned path). A deny here is NOT overridable
|
|
48
|
+
* by the allow-list (deny precedence).
|
|
49
|
+
*/
|
|
50
|
+
export declare const PROTECTED_SUBPATHS: readonly [".git/hooks", ".personaxis"];
|
|
51
|
+
/** Named permission profiles (FR.8) — one word instead of four knobs. */
|
|
52
|
+
export declare const PERMISSION_PROFILES: {
|
|
53
|
+
readonly strict: {
|
|
54
|
+
readonly sandbox: "read-only";
|
|
55
|
+
readonly approval: "untrusted";
|
|
56
|
+
};
|
|
57
|
+
readonly standard: {
|
|
58
|
+
readonly sandbox: "workspace-write";
|
|
59
|
+
readonly approval: "on-request";
|
|
60
|
+
};
|
|
61
|
+
readonly trusted: {
|
|
62
|
+
readonly sandbox: "workspace-write";
|
|
63
|
+
readonly approval: "on-failure";
|
|
64
|
+
};
|
|
65
|
+
readonly yolo: {
|
|
66
|
+
readonly sandbox: "danger-full-access";
|
|
67
|
+
readonly approval: "never";
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
export type PermissionProfile = keyof typeof PERMISSION_PROFILES;
|
|
71
|
+
/** Build a Policy from a named profile (+ optional overrides). */
|
|
72
|
+
export declare function policyFromProfile(profile: PermissionProfile, overrides?: Partial<Policy>): Policy;
|
|
73
|
+
/** True when `p` lands inside a protected subpath of any writable root. */
|
|
74
|
+
export declare function isProtectedPath(p: string, policy: Policy): boolean;
|
|
75
|
+
/**
|
|
76
|
+
* v0.8: build a Policy from a persona's declared `permissions` block, so a persona
|
|
77
|
+
* carries its own sandbox posture to any host. Missing fields fall back to defaults.
|
|
78
|
+
*/
|
|
79
|
+
export declare function policyFromFrontmatter(frontmatter: Record<string, unknown>, workspaceRoot?: string): Policy;
|
|
80
|
+
export interface CommandClass {
|
|
81
|
+
writesFiles: boolean;
|
|
82
|
+
network: boolean;
|
|
83
|
+
destructive: boolean;
|
|
84
|
+
escapesWorkspace: boolean;
|
|
85
|
+
}
|
|
86
|
+
/** Heuristically classify what a shell command would do. */
|
|
87
|
+
export declare function classifyCommand(cmd: string, workspaceRoot: string): CommandClass;
|
|
88
|
+
/** True if `p` resolves outside `root`. */
|
|
89
|
+
export declare function pathEscapesWorkspace(p: string, root: string): boolean;
|
|
90
|
+
export type Decision = "allow" | "ask" | "deny";
|
|
91
|
+
export interface CommandVerdict {
|
|
92
|
+
decision: Decision;
|
|
93
|
+
reason: string;
|
|
94
|
+
class: CommandClass;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Decide allow | ask | deny for a command under a policy. Precedence:
|
|
98
|
+
* deny-list > sandbox hard limits > allow-list > approval mode.
|
|
99
|
+
*/
|
|
100
|
+
export declare function evaluateCommand(cmd: string, policy?: Policy): CommandVerdict;
|
|
101
|
+
/**
|
|
102
|
+
* Decide allow | ask | deny for a FILE WRITE/EDIT under a policy. Mirrors
|
|
103
|
+
* evaluateCommand's precedence but for a path target (the agent's write_file /
|
|
104
|
+
* edit_file tools). Reuses pathEscapesWorkspace so a write that escapes the
|
|
105
|
+
* workspace is denied under workspace-write, exactly like a shell redirect would be.
|
|
106
|
+
*/
|
|
107
|
+
export declare function evaluateFileWrite(targetPath: string, policy?: Policy, opts?: {
|
|
108
|
+
destructive?: boolean;
|
|
109
|
+
}): CommandVerdict;
|
|
110
|
+
export interface WrapResult {
|
|
111
|
+
command: string;
|
|
112
|
+
args: string[];
|
|
113
|
+
sandbox: "seatbelt" | "bubblewrap" | "none";
|
|
114
|
+
note: string;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Best-effort native sandbox wrapping for an ALLOWED command. Caller is
|
|
118
|
+
* responsible for only wrapping commands that already passed evaluateCommand.
|
|
119
|
+
*/
|
|
120
|
+
export declare function wrapCommand(cmd: string, policy?: Policy): WrapResult;
|
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission & sandbox policy (F3 / T9 — plan/11-security).
|
|
3
|
+
*
|
|
4
|
+
* Honest scope: real *kernel* sandboxing needs native primitives (macOS Seatbelt,
|
|
5
|
+
* Linux Landlock/seccomp/bubblewrap, Windows job objects / restricted tokens).
|
|
6
|
+
* This module provides two things that ARE doable cross-platform and that the big
|
|
7
|
+
* agents rely on most:
|
|
8
|
+
*
|
|
9
|
+
* 1. A two-axis POLICY ENGINE (approval × sandbox, the Codex model) that DECIDES
|
|
10
|
+
* allow | ask | deny for a command — pure, deterministic, fully tested. This
|
|
11
|
+
* is the load-bearing control: a denied command never runs.
|
|
12
|
+
* 2. A best-effort NATIVE WRAPPER that, when the command is allowed, wraps it
|
|
13
|
+
* with the platform's available sandbox (sandbox-exec / bwrap) so writes and
|
|
14
|
+
* network are constrained at the OS level where possible.
|
|
15
|
+
*
|
|
16
|
+
* If no native sandbox is available, enforcement degrades to the policy decision
|
|
17
|
+
* (deny-by-default for risky ops) — never a silent full-access fallback.
|
|
18
|
+
*/
|
|
19
|
+
import { platform } from "node:os";
|
|
20
|
+
import { isAbsolute, normalize, relative } from "node:path";
|
|
21
|
+
export const DEFAULT_POLICY = {
|
|
22
|
+
sandbox: "workspace-write",
|
|
23
|
+
approval: "on-request",
|
|
24
|
+
allow: [],
|
|
25
|
+
deny: [],
|
|
26
|
+
workspaceRoot: process.cwd(),
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* FR.8 (Codex protocol.rs anti-escalation): subpaths that stay PROTECTED even
|
|
30
|
+
* inside a writable root. `.git/hooks` = arbitrary-code-execution escalation
|
|
31
|
+
* (a write there runs on the user's next git command); `.personaxis` = the
|
|
32
|
+
* persona's identity artifacts — raw file writes would bypass the governance
|
|
33
|
+
* ledger (self-edits are the sanctioned path). A deny here is NOT overridable
|
|
34
|
+
* by the allow-list (deny precedence).
|
|
35
|
+
*/
|
|
36
|
+
export const PROTECTED_SUBPATHS = [".git/hooks", ".personaxis"];
|
|
37
|
+
/** Named permission profiles (FR.8) — one word instead of four knobs. */
|
|
38
|
+
export const PERMISSION_PROFILES = {
|
|
39
|
+
strict: { sandbox: "read-only", approval: "untrusted" },
|
|
40
|
+
standard: { sandbox: "workspace-write", approval: "on-request" },
|
|
41
|
+
trusted: { sandbox: "workspace-write", approval: "on-failure" },
|
|
42
|
+
yolo: { sandbox: "danger-full-access", approval: "never" },
|
|
43
|
+
};
|
|
44
|
+
/** Build a Policy from a named profile (+ optional overrides). */
|
|
45
|
+
export function policyFromProfile(profile, overrides = {}) {
|
|
46
|
+
return { ...DEFAULT_POLICY, ...PERMISSION_PROFILES[profile], ...overrides };
|
|
47
|
+
}
|
|
48
|
+
/** True when `p` lands inside a protected subpath of any writable root. */
|
|
49
|
+
export function isProtectedPath(p, policy) {
|
|
50
|
+
const roots = [policy.workspaceRoot, ...(policy.writableRoots ?? [])];
|
|
51
|
+
const abs = isAbsolute(p) ? normalize(p) : normalize(`${policy.workspaceRoot}/${p}`);
|
|
52
|
+
for (const root of roots) {
|
|
53
|
+
for (const sub of PROTECTED_SUBPATHS) {
|
|
54
|
+
const guard = normalize(`${root}/${sub}`);
|
|
55
|
+
const rel = relative(guard, abs);
|
|
56
|
+
if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)))
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* v0.8: build a Policy from a persona's declared `permissions` block, so a persona
|
|
64
|
+
* carries its own sandbox posture to any host. Missing fields fall back to defaults.
|
|
65
|
+
*/
|
|
66
|
+
export function policyFromFrontmatter(frontmatter, workspaceRoot = process.cwd()) {
|
|
67
|
+
const p = (frontmatter.permissions ?? {});
|
|
68
|
+
const sandbox = p.sandbox === "read-only" || p.sandbox === "workspace-write" || p.sandbox === "danger-full-access"
|
|
69
|
+
? p.sandbox
|
|
70
|
+
: DEFAULT_POLICY.sandbox;
|
|
71
|
+
const approval = p.approval === "untrusted" || p.approval === "on-failure" || p.approval === "on-request" || p.approval === "never"
|
|
72
|
+
? p.approval
|
|
73
|
+
: DEFAULT_POLICY.approval;
|
|
74
|
+
return {
|
|
75
|
+
sandbox,
|
|
76
|
+
approval,
|
|
77
|
+
allow: Array.isArray(p.allow) ? p.allow.filter((x) => typeof x === "string") : [],
|
|
78
|
+
deny: Array.isArray(p.deny) ? p.deny.filter((x) => typeof x === "string") : [],
|
|
79
|
+
workspaceRoot,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const NETWORK = /\b(curl|wget|nc|ncat|ssh|scp|telnet|ftp|rsync)\b|\bnpm\s+(install|i|publish)\b|\bpip\s+install\b/i;
|
|
83
|
+
const WRITE = />>?|\b(rm|mv|cp|mkdir|touch|tee|dd|truncate|chmod|chown|ln)\b/i;
|
|
84
|
+
const DESTRUCTIVE = /\brm\s+-[a-z]*f|\b(mkfs|fdisk|shred|:\(\)\s*\{)/i;
|
|
85
|
+
/**
|
|
86
|
+
* A leading-slash token that is really a Windows/CLI SWITCH, not a filesystem path
|
|
87
|
+
* (e.g. `/t`, `/c`, `/s`, `/?`, `/all`, `/b`). Real escaping paths look like
|
|
88
|
+
* `/etc/passwd`, `/usr`, `/tmp`, `~/x`, `../x`. We exclude switch-shaped tokens so
|
|
89
|
+
* harmless commands like `date /t` or `dir /s` aren't misflagged as workspace escapes.
|
|
90
|
+
*/
|
|
91
|
+
function isCliSwitch(tok) {
|
|
92
|
+
return /^\/(\?|[a-zA-Z]{1,3}(:[A-Za-z0-9_-]+)?)$/.test(tok);
|
|
93
|
+
}
|
|
94
|
+
/** Heuristically classify what a shell command would do. */
|
|
95
|
+
export function classifyCommand(cmd, workspaceRoot) {
|
|
96
|
+
const writesFiles = WRITE.test(cmd);
|
|
97
|
+
const network = NETWORK.test(cmd);
|
|
98
|
+
const destructive = DESTRUCTIVE.test(cmd);
|
|
99
|
+
const escapesWorkspace = (cmd.match(/(?:^|\s)(\/[^\s'"]+|[~][^\s'"]*|\.\.\/[^\s'"]*)/g) ?? [])
|
|
100
|
+
.map((tok) => tok.trim())
|
|
101
|
+
.filter((tok) => !isCliSwitch(tok))
|
|
102
|
+
.some((tok) => pathEscapesWorkspace(tok, workspaceRoot));
|
|
103
|
+
return { writesFiles, network, destructive, escapesWorkspace };
|
|
104
|
+
}
|
|
105
|
+
/** True if `p` resolves outside `root`. */
|
|
106
|
+
export function pathEscapesWorkspace(p, root) {
|
|
107
|
+
if (p.startsWith("~"))
|
|
108
|
+
return true;
|
|
109
|
+
if (!isAbsolute(p) && !p.startsWith(".."))
|
|
110
|
+
return false;
|
|
111
|
+
const rel = relative(normalize(root), normalize(isAbsolute(p) ? p : `${root}/${p}`));
|
|
112
|
+
return rel.startsWith("..") || isAbsolute(rel);
|
|
113
|
+
}
|
|
114
|
+
function matchesAny(patterns, cmd) {
|
|
115
|
+
return patterns.some((p) => {
|
|
116
|
+
try {
|
|
117
|
+
return new RegExp(p).test(cmd);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Decide allow | ask | deny for a command under a policy. Precedence:
|
|
126
|
+
* deny-list > sandbox hard limits > allow-list > approval mode.
|
|
127
|
+
*/
|
|
128
|
+
export function evaluateCommand(cmd, policy = DEFAULT_POLICY) {
|
|
129
|
+
const klass = classifyCommand(cmd, policy.workspaceRoot);
|
|
130
|
+
if (matchesAny(policy.deny, cmd)) {
|
|
131
|
+
return { decision: "deny", reason: "matches deny-list", class: klass };
|
|
132
|
+
}
|
|
133
|
+
// danger-full-access = explicit YOLO: allow everything the deny-list didn't block, with NO
|
|
134
|
+
// approval prompt. This is what makes the posture meaningfully different from workspace-write
|
|
135
|
+
// (which still asks for risky ops) and consistent with wrapCommand's "full access (no wrapping)".
|
|
136
|
+
if (policy.sandbox === "danger-full-access") {
|
|
137
|
+
return { decision: "allow", reason: "danger-full-access (no restrictions except deny-list)", class: klass };
|
|
138
|
+
}
|
|
139
|
+
// Sandbox hard limits (independent of approval).
|
|
140
|
+
if (policy.sandbox === "read-only" && (klass.writesFiles || klass.network)) {
|
|
141
|
+
return { decision: "deny", reason: "read-only sandbox forbids writes/network", class: klass };
|
|
142
|
+
}
|
|
143
|
+
if (policy.sandbox === "workspace-write") {
|
|
144
|
+
if (klass.escapesWorkspace && klass.writesFiles) {
|
|
145
|
+
return { decision: "deny", reason: "write escapes the workspace", class: klass };
|
|
146
|
+
}
|
|
147
|
+
if (klass.destructive) {
|
|
148
|
+
return { decision: "deny", reason: "destructive command blocked under workspace-write", class: klass };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (matchesAny(policy.allow, cmd)) {
|
|
152
|
+
return { decision: "allow", reason: "matches allow-list", class: klass };
|
|
153
|
+
}
|
|
154
|
+
// Approval mode governs the residual risk. FR.8: a per-category override
|
|
155
|
+
// (approvals.network/destructive/write) takes precedence over the global
|
|
156
|
+
// knob for commands of that class — most-specific-first, strictest-wins
|
|
157
|
+
// when a command falls in several categories.
|
|
158
|
+
const risky = klass.writesFiles || klass.network || klass.destructive || klass.escapesWorkspace;
|
|
159
|
+
const effective = effectiveApproval(policy, klass);
|
|
160
|
+
switch (effective) {
|
|
161
|
+
case "never":
|
|
162
|
+
return { decision: "allow", reason: "approval=never", class: klass };
|
|
163
|
+
case "on-failure":
|
|
164
|
+
return { decision: "allow", reason: "approval=on-failure (pre-approved)", class: klass };
|
|
165
|
+
case "on-request":
|
|
166
|
+
return risky
|
|
167
|
+
? { decision: "ask", reason: "risky op needs approval", class: klass }
|
|
168
|
+
: { decision: "allow", reason: "read-only op", class: klass };
|
|
169
|
+
case "untrusted":
|
|
170
|
+
default:
|
|
171
|
+
return risky
|
|
172
|
+
? { decision: "ask", reason: "untrusted: confirm any risky op", class: klass }
|
|
173
|
+
: { decision: "allow", reason: "read-only op", class: klass };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
/** Strictness order for approval modes (stricter = later). */
|
|
177
|
+
const APPROVAL_STRICTNESS = ["never", "on-failure", "on-request", "untrusted"];
|
|
178
|
+
/** FR.8: resolve the approval mode for a classified command (strictest category wins). */
|
|
179
|
+
function effectiveApproval(policy, klass) {
|
|
180
|
+
const candidates = [policy.approval];
|
|
181
|
+
const a = policy.approvals;
|
|
182
|
+
if (a) {
|
|
183
|
+
if (klass.network && a.network)
|
|
184
|
+
candidates.push(a.network);
|
|
185
|
+
if (klass.destructive && a.destructive)
|
|
186
|
+
candidates.push(a.destructive);
|
|
187
|
+
if (klass.writesFiles && a.write)
|
|
188
|
+
candidates.push(a.write);
|
|
189
|
+
}
|
|
190
|
+
return candidates.reduce((strictest, m) => APPROVAL_STRICTNESS.indexOf(m) > APPROVAL_STRICTNESS.indexOf(strictest) ? m : strictest);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Decide allow | ask | deny for a FILE WRITE/EDIT under a policy. Mirrors
|
|
194
|
+
* evaluateCommand's precedence but for a path target (the agent's write_file /
|
|
195
|
+
* edit_file tools). Reuses pathEscapesWorkspace so a write that escapes the
|
|
196
|
+
* workspace is denied under workspace-write, exactly like a shell redirect would be.
|
|
197
|
+
*/
|
|
198
|
+
export function evaluateFileWrite(targetPath, policy = DEFAULT_POLICY, opts = {}) {
|
|
199
|
+
// FR.8: writable under workspace-write ⇔ inside ANY writable root.
|
|
200
|
+
const roots = [policy.workspaceRoot, ...(policy.writableRoots ?? [])];
|
|
201
|
+
const klass = {
|
|
202
|
+
writesFiles: true,
|
|
203
|
+
network: false,
|
|
204
|
+
destructive: Boolean(opts.destructive),
|
|
205
|
+
escapesWorkspace: roots.every((r) => pathEscapesWorkspace(targetPath, r)),
|
|
206
|
+
};
|
|
207
|
+
// Anti-escalation guard FIRST — not even the allow-list overrides it.
|
|
208
|
+
if (isProtectedPath(targetPath, policy)) {
|
|
209
|
+
return {
|
|
210
|
+
decision: "deny",
|
|
211
|
+
reason: "protected subpath (.git/hooks = code-execution escalation; .personaxis = governed identity artifacts — use the sanctioned edit tools)",
|
|
212
|
+
class: klass,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
if (matchesAny(policy.deny, targetPath)) {
|
|
216
|
+
return { decision: "deny", reason: "path matches deny-list", class: klass };
|
|
217
|
+
}
|
|
218
|
+
// danger-full-access = explicit YOLO: writes are allowed without an approval prompt (only the
|
|
219
|
+
// deny-list still blocks). Mirrors evaluateCommand so cycling to this posture is meaningful.
|
|
220
|
+
if (policy.sandbox === "danger-full-access") {
|
|
221
|
+
return { decision: "allow", reason: "danger-full-access (writes allowed, deny-list still applies)", class: klass };
|
|
222
|
+
}
|
|
223
|
+
if (policy.sandbox === "read-only") {
|
|
224
|
+
return { decision: "deny", reason: "read-only sandbox forbids writes", class: klass };
|
|
225
|
+
}
|
|
226
|
+
if (policy.sandbox === "workspace-write" && klass.escapesWorkspace) {
|
|
227
|
+
return { decision: "deny", reason: "write escapes the workspace", class: klass };
|
|
228
|
+
}
|
|
229
|
+
if (matchesAny(policy.allow, targetPath)) {
|
|
230
|
+
return { decision: "allow", reason: "path matches allow-list", class: klass };
|
|
231
|
+
}
|
|
232
|
+
switch (policy.approval) {
|
|
233
|
+
case "never":
|
|
234
|
+
case "on-failure":
|
|
235
|
+
return { decision: "allow", reason: `approval=${policy.approval}`, class: klass };
|
|
236
|
+
case "on-request":
|
|
237
|
+
case "untrusted":
|
|
238
|
+
default:
|
|
239
|
+
return { decision: "ask", reason: "file write needs approval", class: klass };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Best-effort native sandbox wrapping for an ALLOWED command. Caller is
|
|
244
|
+
* responsible for only wrapping commands that already passed evaluateCommand.
|
|
245
|
+
*/
|
|
246
|
+
export function wrapCommand(cmd, policy = DEFAULT_POLICY) {
|
|
247
|
+
const os = platform();
|
|
248
|
+
if (policy.sandbox === "danger-full-access") {
|
|
249
|
+
return { command: "sh", args: ["-c", cmd], sandbox: "none", note: "full access (no wrapping)" };
|
|
250
|
+
}
|
|
251
|
+
if (os === "darwin") {
|
|
252
|
+
// macOS Seatbelt: deny network, allow workspace writes only.
|
|
253
|
+
const profile = `(version 1)(allow default)` +
|
|
254
|
+
(policy.sandbox === "read-only" ? `(deny file-write*)` : `(allow file-write* (subpath "${policy.workspaceRoot}"))(deny file-write*)`) +
|
|
255
|
+
`(deny network*)`;
|
|
256
|
+
return { command: "sandbox-exec", args: ["-p", profile, "sh", "-c", cmd], sandbox: "seatbelt", note: "macOS Seatbelt profile" };
|
|
257
|
+
}
|
|
258
|
+
if (os === "linux") {
|
|
259
|
+
// Linux bubblewrap: read-only bind of /, writable workspace, no network.
|
|
260
|
+
const args = ["--ro-bind", "/", "/", "--bind", policy.workspaceRoot, policy.workspaceRoot, "--unshare-net", "--dev", "/dev", "sh", "-c", cmd];
|
|
261
|
+
return { command: "bwrap", args, sandbox: "bubblewrap", note: "Linux bubblewrap (requires bwrap on PATH)" };
|
|
262
|
+
}
|
|
263
|
+
// Windows / other: no portable kernel sandbox here; rely on the policy decision.
|
|
264
|
+
return { command: "sh", args: ["-c", cmd], sandbox: "none", note: `${os}: no native sandbox wrapper; policy decision is the control` };
|
|
265
|
+
}
|