tunestra 0.3.0 → 0.4.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/README.md +27 -9
- package/dist/cli.mjs +283 -32
- package/dist/index.mjs +1 -1
- package/dist/{src-BNeh64fK.mjs → src-Bh7d7ERC.mjs} +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
> before v1.0.
|
|
14
14
|
|
|
15
15
|
tunestra gives Claude Code, Codex and other AI coding agents **one shared source** of rules,
|
|
16
|
-
skills, commands, MCP servers
|
|
17
|
-
configuration from it.
|
|
16
|
+
skills, commands, MCP servers, environment variables and permissions, and generates each
|
|
17
|
+
agent's native configuration from it.
|
|
18
18
|
|
|
19
19
|
The source is a chain of **presets**, from general to specific:
|
|
20
20
|
|
|
@@ -45,8 +45,8 @@ extends:
|
|
|
45
45
|
agents: [claude] # add codex: [claude, codex]
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
Commit `tunestra.yaml`, `.tunestra/`, `AGENTS.md`, `CLAUDE.md` and, when presets set `env
|
|
49
|
-
`.claude/settings.json`. Everything else that tunestra generates is listed in `.gitignore`
|
|
48
|
+
Commit `tunestra.yaml`, `.tunestra/`, `AGENTS.md`, `CLAUDE.md` and, when presets set `env` or
|
|
49
|
+
`permissions`, `.claude/settings.json`. Everything else that tunestra generates is listed in `.gitignore`
|
|
50
50
|
automatically.
|
|
51
51
|
|
|
52
52
|
## Principles
|
|
@@ -90,6 +90,10 @@ overrides: # optional: patch inherited MCP servers
|
|
|
90
90
|
QUEUE: TEAM
|
|
91
91
|
env: # optional: environment variables of the agents
|
|
92
92
|
MCP_TIMEOUT: 120000
|
|
93
|
+
permissions: # optional: rules in the syntax of Claude Code
|
|
94
|
+
allow: [Bash(npm test *)]
|
|
95
|
+
ask: [Bash(git push *)]
|
|
96
|
+
deny: [Read(./.env), Bash(rm -rf *)]
|
|
93
97
|
```
|
|
94
98
|
|
|
95
99
|
### Project: `tunestra.yaml`
|
|
@@ -141,6 +145,10 @@ chain once, before both of them.
|
|
|
141
145
|
| Command | file name without `.md` | replaces it | `exclude.commands` |
|
|
142
146
|
| MCP server | file name without `.yaml` | replaces it | `exclude.mcp` |
|
|
143
147
|
| Environment variable | name in `env` | replaces it | `exclude.env` |
|
|
148
|
+
| Permission rule | the rule, like `Bash(npm test *)` | lists are joined: every level adds its rules | `exclude.permissions`, only for `allow` and `ask` |
|
|
149
|
+
|
|
150
|
+
A `deny` rule cannot be removed: the organization keeps a floor of what agents may never do.
|
|
151
|
+
Agents check `deny` first, so a `deny` rule on any level wins over an `allow` rule.
|
|
144
152
|
|
|
145
153
|
`overrides.mcp.<key>` patches an inherited server instead of replacing it: `env` and `headers`
|
|
146
154
|
are merged by key (the specific value wins); `command`, `args` and `url` are replaced.
|
|
@@ -157,7 +165,8 @@ are merged by key (the specific value wins); `command`, `args` and `url` are rep
|
|
|
157
165
|
- after a patch, an MCP server has both `command` and `url`, or neither;
|
|
158
166
|
- an `env` value refers to another variable, like `${TOKEN}`: agents do not expand it in
|
|
159
167
|
their settings, so write the value itself and keep secrets out of presets;
|
|
160
|
-
- `env` sets a variable that Claude Code ignores in project settings (see below)
|
|
168
|
+
- `env` sets a variable that Claude Code ignores in project settings (see below);
|
|
169
|
+
- `exclude.permissions` tries to remove a `deny` rule.
|
|
161
170
|
|
|
162
171
|
All problems are reported at once, in the order of the files, so you can fix them in one pass.
|
|
163
172
|
|
|
@@ -182,15 +191,16 @@ For every agent:
|
|
|
182
191
|
| `.claude/skills/<key>` | Symlinks to `.agents/skills/<key>` | no |
|
|
183
192
|
| `.claude/commands/<key>.md` | Commands | no |
|
|
184
193
|
| `.mcp.json` | MCP servers | no |
|
|
185
|
-
| `.claude/settings.json` | `env`, when presets set
|
|
194
|
+
| `.claude/settings.json` | `env` and `permissions`, when presets set them | yes |
|
|
186
195
|
|
|
187
196
|
`.claude/settings.json` is shared by the team, so tunestra writes it and you commit it. Keep
|
|
188
197
|
your personal settings in `.claude/settings.local.json` or `~/.claude/settings.json`. A
|
|
189
198
|
`.claude/settings.json` with other keys, like `hooks` or `model`, stops setup and asks you to
|
|
190
|
-
move them; a file with only `$schema` and `
|
|
199
|
+
move them; a file with only `$schema`, `env` and `permissions` is replaced with a warning, for example in a
|
|
191
200
|
fresh clone where `.tunestra/state/` does not exist yet.
|
|
192
201
|
|
|
193
|
-
Claude Code applies project `env` after you trust the folder
|
|
202
|
+
Claude Code applies project `env` and `allow` rules after you trust the folder; `deny` and
|
|
203
|
+
`ask` rules apply right away. It ignores some variables in
|
|
194
204
|
project settings: the ones that turn telemetry on or choose where it goes
|
|
195
205
|
(`CLAUDE_CODE_ENABLE_TELEMETRY`, `OTEL_*_EXPORTER`, `OTEL_EXPORTER_OTLP_*` and others), and
|
|
196
206
|
the ones that choose its own directories (`HOME`, `XDG_*`, `CLAUDE_CONFIG_DIR`). setup stops
|
|
@@ -205,11 +215,19 @@ Codex reads `AGENTS.md` and `.agents/skills/` by itself. It does not follow the
|
|
|
205
215
|
| Path | Content | Committed |
|
|
206
216
|
|---|---|---|
|
|
207
217
|
| `.codex/config.toml` | `developer_instructions` with every rule, from specific to general, `[shell_environment_policy] set` with `env`, and `[mcp_servers]` | no |
|
|
218
|
+
| `.codex/rules/tunestra.rules` | `deny` and `ask` rules for shell commands as `prefix_rule`: `forbidden` and `prompt` | no |
|
|
208
219
|
| `.agents/skills/<command>/` | Each command as a skill that runs only when you call it: `$release-notes` | no |
|
|
209
220
|
|
|
210
221
|
Codex reads `.codex/config.toml` only in a trusted project: trust the project when Codex asks.
|
|
211
222
|
Codex gets `env` as the environment of the commands it runs; it does not change how Codex
|
|
212
223
|
itself works, and Codex ignores telemetry settings in a project.
|
|
224
|
+
|
|
225
|
+
Codex rules match only the start of a shell command, so tunestra converts only `Bash` rules
|
|
226
|
+
like `Bash(git push *)` or `Bash(rm -rf)`. A rule without a wildcard also becomes a prefix,
|
|
227
|
+
which is stricter for `deny` and `ask`. Other rules (`Read`, `Edit`, `WebFetch`, MCP tools, and
|
|
228
|
+
`Bash` rules with wildcards in the middle or shell syntax) are skipped with a note. `allow`
|
|
229
|
+
rules are skipped too: in Codex, an allowed command runs outside the sandbox without asking,
|
|
230
|
+
which is more than an `allow` rule of Claude Code means.
|
|
213
231
|
Codex has no `type` for MCP servers and does not support SSE: an SSE server is skipped with a
|
|
214
232
|
note, and `headers` become `http_headers`. tunestra owns the whole `.codex/config.toml`; keep
|
|
215
233
|
your personal Codex settings in `~/.codex/config.toml`.
|
|
@@ -264,7 +282,7 @@ and tells you where to move their text.
|
|
|
264
282
|
|---|---|
|
|
265
283
|
| v0.1 | Inheritance for Claude Code |
|
|
266
284
|
| v0.2 | Codex (this document) |
|
|
267
|
-
| v0.3 | `env
|
|
285
|
+
| v0.3 | `env` and permissions, then subagents and a personal `local` level |
|
|
268
286
|
| v0.4 | Spaces, npm sources, lockfile |
|
|
269
287
|
| v0.5 | `required` / `locked` policies, CI check |
|
|
270
288
|
| v0.6 | Hooks |
|
package/dist/cli.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { t as VERSION } from "./src-
|
|
2
|
+
import { t as VERSION } from "./src-Bh7d7ERC.mjs";
|
|
3
3
|
import { Command } from "@commander-js/extra-typings";
|
|
4
4
|
import pc from "picocolors";
|
|
5
5
|
import { copyFile, lstat, mkdir, readFile, readdir, readlink, rename, rm, rmdir, stat, symlink, unlink, writeFile } from "node:fs/promises";
|
|
@@ -338,13 +338,21 @@ const envSchema = z.record(z.string(), envValue).superRefine((env, context) => {
|
|
|
338
338
|
path: [name]
|
|
339
339
|
});
|
|
340
340
|
});
|
|
341
|
+
const permissionRules = z.array(z.string().regex(/^[A-Za-z][\w*-]*(\(.+\))?$/, "Write a rule as Tool or Tool(specifier), like Bash(npm test *)")).readonly();
|
|
342
|
+
const permissionsSchema = z.strictObject({
|
|
343
|
+
allow: permissionRules.optional(),
|
|
344
|
+
ask: permissionRules.optional(),
|
|
345
|
+
deny: permissionRules.optional()
|
|
346
|
+
});
|
|
341
347
|
const skillName = z.string().regex(SKILL_NAME_PATTERN, "Use lowercase letters, digits and single hyphens").max(64);
|
|
342
348
|
const excludeSchema = z.strictObject({
|
|
343
349
|
rules: keys.optional(),
|
|
344
350
|
skills: keys.optional(),
|
|
345
351
|
commands: keys.optional(),
|
|
346
352
|
mcp: keys.optional(),
|
|
347
|
-
env: keys.optional()
|
|
353
|
+
env: keys.optional(),
|
|
354
|
+
/** Inherited `allow` and `ask` rules; a `deny` rule cannot be removed. */
|
|
355
|
+
permissions: keys.optional()
|
|
348
356
|
});
|
|
349
357
|
const mcpPatchSchema = z.strictObject({
|
|
350
358
|
command: z.string().optional(),
|
|
@@ -361,7 +369,8 @@ const presetManifestSchema = z.strictObject({
|
|
|
361
369
|
extends: z.array(z.string().min(1)).readonly().default([]),
|
|
362
370
|
exclude: excludeSchema.default({}),
|
|
363
371
|
overrides: overridesSchema.default({}),
|
|
364
|
-
env: envSchema.default({})
|
|
372
|
+
env: envSchema.default({}),
|
|
373
|
+
permissions: permissionsSchema.default({})
|
|
365
374
|
});
|
|
366
375
|
const projectConfigSchema = z.strictObject({
|
|
367
376
|
name: z.string().min(1).optional(),
|
|
@@ -369,7 +378,8 @@ const projectConfigSchema = z.strictObject({
|
|
|
369
378
|
agents: z.array(z.enum(Agent)).min(1).readonly().default([Agent.Claude]),
|
|
370
379
|
exclude: excludeSchema.default({}),
|
|
371
380
|
overrides: overridesSchema.default({}),
|
|
372
|
-
env: envSchema.default({})
|
|
381
|
+
env: envSchema.default({}),
|
|
382
|
+
permissions: permissionsSchema.default({})
|
|
373
383
|
});
|
|
374
384
|
const mcpServerSchema = z.strictObject({
|
|
375
385
|
description: z.string().optional(),
|
|
@@ -635,6 +645,34 @@ function createEnvVariables(env, manifestPath, artifactsDir) {
|
|
|
635
645
|
}));
|
|
636
646
|
}
|
|
637
647
|
//#endregion
|
|
648
|
+
//#region src/shared/constants/permissions.ts
|
|
649
|
+
/** What an agent does with a tool call that matches a permission rule. */
|
|
650
|
+
const PermissionDecision = {
|
|
651
|
+
/** Run without asking. */
|
|
652
|
+
Allow: "allow",
|
|
653
|
+
/** Ask every time. */
|
|
654
|
+
Ask: "ask",
|
|
655
|
+
/** Never run. Agents check deny first, so a deny rule always wins. */
|
|
656
|
+
Deny: "deny"
|
|
657
|
+
};
|
|
658
|
+
//#endregion
|
|
659
|
+
//#region src/presets/permissions.ts
|
|
660
|
+
const DECISIONS$1 = Object.values(PermissionDecision);
|
|
661
|
+
/** The `permissions` lists of a manifest as one list of rules, in the order of the file. */
|
|
662
|
+
function createPermissionRules(permissions, manifestPath, artifactsDir) {
|
|
663
|
+
const source = relativePosix(artifactsDir, manifestPath);
|
|
664
|
+
const rules = [];
|
|
665
|
+
for (const decision of DECISIONS$1) {
|
|
666
|
+
const keys = permissions[decision] ?? [];
|
|
667
|
+
for (const key of keys) rules.push({
|
|
668
|
+
key,
|
|
669
|
+
decision,
|
|
670
|
+
source
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
return rules;
|
|
674
|
+
}
|
|
675
|
+
//#endregion
|
|
638
676
|
//#region src/presets/load-project.ts
|
|
639
677
|
async function loadProject(dir) {
|
|
640
678
|
const root = resolve(dir);
|
|
@@ -646,6 +684,7 @@ async function loadProject(dir) {
|
|
|
646
684
|
const artifactsDir = join(root, PROJECT_DIR);
|
|
647
685
|
const artifacts = await loadArtifacts(artifactsDir, name);
|
|
648
686
|
const env = createEnvVariables(config.env, configPath, artifactsDir);
|
|
687
|
+
const permissions = createPermissionRules(config.permissions, configPath, artifactsDir);
|
|
649
688
|
return {
|
|
650
689
|
...config,
|
|
651
690
|
name,
|
|
@@ -655,7 +694,8 @@ async function loadProject(dir) {
|
|
|
655
694
|
artifactsDir,
|
|
656
695
|
remote: void 0,
|
|
657
696
|
...artifacts,
|
|
658
|
-
env
|
|
697
|
+
env,
|
|
698
|
+
permissions
|
|
659
699
|
};
|
|
660
700
|
}
|
|
661
701
|
//#endregion
|
|
@@ -861,6 +901,7 @@ async function loadPreset(dir) {
|
|
|
861
901
|
const manifest = await readYaml(manifestPath, presetManifestSchema, "preset manifest");
|
|
862
902
|
const artifacts = await loadArtifacts(root, manifest.name);
|
|
863
903
|
const env = createEnvVariables(manifest.env, manifestPath, root);
|
|
904
|
+
const permissions = createPermissionRules(manifest.permissions, manifestPath, root);
|
|
864
905
|
return {
|
|
865
906
|
...manifest,
|
|
866
907
|
description: manifest.description,
|
|
@@ -868,7 +909,8 @@ async function loadPreset(dir) {
|
|
|
868
909
|
artifactsDir: root,
|
|
869
910
|
remote: void 0,
|
|
870
911
|
...artifacts,
|
|
871
|
-
env
|
|
912
|
+
env,
|
|
913
|
+
permissions
|
|
872
914
|
};
|
|
873
915
|
}
|
|
874
916
|
//#endregion
|
|
@@ -1054,14 +1096,31 @@ function compareByName([a], [b]) {
|
|
|
1054
1096
|
//#endregion
|
|
1055
1097
|
//#region src/agents/claude/settings-json.ts
|
|
1056
1098
|
const SETTINGS_SCHEMA = "https://json.schemastore.org/claude-code-settings.json";
|
|
1099
|
+
const DECISIONS = Object.values(PermissionDecision);
|
|
1057
1100
|
/** The keys that tunestra writes; a file with only these keys is a generated one. */
|
|
1058
|
-
const SETTINGS_KEYS = [
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1101
|
+
const SETTINGS_KEYS = [
|
|
1102
|
+
"$schema",
|
|
1103
|
+
"env",
|
|
1104
|
+
"permissions"
|
|
1105
|
+
];
|
|
1106
|
+
/** `.claude/settings.json` with sorted keys and lists, so that its diff stays small. */
|
|
1107
|
+
function renderSettingsJson(settings) {
|
|
1108
|
+
const content = { $schema: SETTINGS_SCHEMA };
|
|
1109
|
+
const hasEnv = settings.env.length > 0;
|
|
1110
|
+
const hasPermissions = settings.permissions.length > 0;
|
|
1111
|
+
if (hasEnv) content.env = buildEnvMap(settings.env);
|
|
1112
|
+
if (hasPermissions) content.permissions = buildPermissionLists(settings.permissions);
|
|
1113
|
+
return `${JSON.stringify(content, null, 2)}\n`;
|
|
1114
|
+
}
|
|
1115
|
+
/** Only the lists that have rules; a rule repeated on several levels is written once. */
|
|
1116
|
+
function buildPermissionLists(permissions) {
|
|
1117
|
+
const lists = {};
|
|
1118
|
+
for (const decision of DECISIONS) {
|
|
1119
|
+
const keys = permissions.filter((resolved) => resolved.artifact.decision === decision).map((resolved) => resolved.artifact.key);
|
|
1120
|
+
const sortedKeys = [...new Set(keys)].toSorted();
|
|
1121
|
+
if (sortedKeys.length > 0) lists[decision] = sortedKeys;
|
|
1122
|
+
}
|
|
1123
|
+
return lists;
|
|
1065
1124
|
}
|
|
1066
1125
|
//#endregion
|
|
1067
1126
|
//#region src/agents/claude/claude-adapter.ts
|
|
@@ -1072,14 +1131,14 @@ const COMMANDS_DIR = ".claude/commands";
|
|
|
1072
1131
|
const MCP_FILE = ".mcp.json";
|
|
1073
1132
|
const MCP_SOURCE = "the mcp/ folders of tunestra.yaml and its presets";
|
|
1074
1133
|
const SETTINGS_FILE = ".claude/settings.json";
|
|
1075
|
-
const SETTINGS_SOURCE = "env in tunestra.yaml and its presets";
|
|
1134
|
+
const SETTINGS_SOURCE = "env and permissions in tunestra.yaml and its presets";
|
|
1076
1135
|
const LOCAL_SETTINGS_FILE = ".claude/settings.local.json";
|
|
1077
1136
|
/**
|
|
1078
1137
|
* Claude Code reads CLAUDE.md, so it imports AGENTS.md. Rules are copied to `.claude/rules`,
|
|
1079
1138
|
* which Claude Code loads in every session; a symlink to a directory outside `.claude`
|
|
1080
1139
|
* would need an approval. Skills are symlinks to the shared `.agents/skills`. Environment
|
|
1081
|
-
* variables go to `.claude/settings.json`, which the team commits;
|
|
1082
|
-
* stays for personal settings.
|
|
1140
|
+
* variables and permissions go to `.claude/settings.json`, which the team commits;
|
|
1141
|
+
* `settings.local.json` stays for personal settings.
|
|
1083
1142
|
*/
|
|
1084
1143
|
const claudeAdapter = {
|
|
1085
1144
|
agent: Agent.Claude,
|
|
@@ -1109,8 +1168,8 @@ function planClaude(context) {
|
|
|
1109
1168
|
outputs.push(mcpFile);
|
|
1110
1169
|
}
|
|
1111
1170
|
const protectedPaths = [CLAUDE_FILE];
|
|
1112
|
-
if (resolution.env.length > 0) {
|
|
1113
|
-
const settingsFile = planSettingsFile(resolution.env);
|
|
1171
|
+
if (resolution.env.length > 0 || resolution.permissions.length > 0) {
|
|
1172
|
+
const settingsFile = planSettingsFile(resolution.env, resolution.permissions);
|
|
1114
1173
|
outputs.push(settingsFile);
|
|
1115
1174
|
protectedPaths.push(SETTINGS_FILE);
|
|
1116
1175
|
}
|
|
@@ -1127,10 +1186,13 @@ function planClaude(context) {
|
|
|
1127
1186
|
};
|
|
1128
1187
|
}
|
|
1129
1188
|
/** Claude Code drops some variables from project settings; setup says so instead. */
|
|
1130
|
-
function planSettingsFile(env) {
|
|
1189
|
+
function planSettingsFile(env, permissions) {
|
|
1131
1190
|
const problems = env.filter((variable) => isIgnoredByClaude(variable.artifact.key, variable.artifact.value)).map((variable) => `${variable.origin.name}: env sets ${variable.artifact.key}, but Claude Code ignores it in project settings. Set it in managed settings, ~/.claude/settings.json or your shell`);
|
|
1132
1191
|
if (problems.length > 0) throw createProblemsError(problems);
|
|
1133
|
-
const content = renderSettingsJson(
|
|
1192
|
+
const content = renderSettingsJson({
|
|
1193
|
+
env,
|
|
1194
|
+
permissions
|
|
1195
|
+
});
|
|
1134
1196
|
const file = createFileOutput(SETTINGS_FILE, content, SETTINGS_SOURCE);
|
|
1135
1197
|
const ownership = {
|
|
1136
1198
|
keys: SETTINGS_KEYS,
|
|
@@ -1240,15 +1302,82 @@ function convertMcpConfig(config) {
|
|
|
1240
1302
|
};
|
|
1241
1303
|
}
|
|
1242
1304
|
//#endregion
|
|
1305
|
+
//#region src/agents/codex/exec-rules.ts
|
|
1306
|
+
/** A Bash rule of Claude Code with a specifier: `Bash(npm test *)`. */
|
|
1307
|
+
const BASH_RULE_START = "Bash(";
|
|
1308
|
+
const BASH_RULE_END = ")";
|
|
1309
|
+
/**
|
|
1310
|
+
* Shell syntax and wildcards that a Codex prefix rule cannot express.
|
|
1311
|
+
*
|
|
1312
|
+
* Matches: "npm test && rm", "echo $HOME", "git * --force", "ls > out", "say 'hi'"
|
|
1313
|
+
* Does not match: "npm test", "git push --force", "rm -rf"
|
|
1314
|
+
*/
|
|
1315
|
+
const SHELL_SYNTAX_PATTERN = /["'`$\\|&;<>(){}*?[\]~]/;
|
|
1316
|
+
const WHITESPACE_PATTERN = /\s+/;
|
|
1317
|
+
/** A trailing ` *` or the older `:*` means "this command with any arguments". */
|
|
1318
|
+
const ANY_ARGUMENTS_SUFFIXES = [" *", ":*"];
|
|
1319
|
+
/**
|
|
1320
|
+
* Codex names for the decisions it gets. `allow` stays out: in Codex it runs a command
|
|
1321
|
+
* outside the sandbox without asking, which is more than an `allow` rule of Claude Code means.
|
|
1322
|
+
*/
|
|
1323
|
+
const CODEX_DECISIONS = {
|
|
1324
|
+
[PermissionDecision.Deny]: "forbidden",
|
|
1325
|
+
[PermissionDecision.Ask]: "prompt"
|
|
1326
|
+
};
|
|
1327
|
+
/**
|
|
1328
|
+
* The words of a Bash rule that Codex can match by prefix, or `undefined` when it cannot.
|
|
1329
|
+
* A rule without a wildcard becomes a prefix too: for `deny` and `ask` this is stricter.
|
|
1330
|
+
*/
|
|
1331
|
+
function convertBashRule(rule) {
|
|
1332
|
+
if ((rule.startsWith(BASH_RULE_START) && rule.endsWith(BASH_RULE_END)) === false) return;
|
|
1333
|
+
const specifier = rule.slice(5, -1);
|
|
1334
|
+
const suffix = ANY_ARGUMENTS_SUFFIXES.find((ending) => specifier.endsWith(ending));
|
|
1335
|
+
const command = typeof suffix === "string" ? specifier.slice(0, -suffix.length) : specifier;
|
|
1336
|
+
if (SHELL_SYNTAX_PATTERN.test(command)) return;
|
|
1337
|
+
const words = command.trim().split(WHITESPACE_PATTERN);
|
|
1338
|
+
if (words.every((word) => word.length > 0)) return words;
|
|
1339
|
+
}
|
|
1340
|
+
function hasCodexDecision(decision) {
|
|
1341
|
+
return decision in CODEX_DECISIONS;
|
|
1342
|
+
}
|
|
1343
|
+
function createExecRule(resolved, decision, pattern) {
|
|
1344
|
+
return {
|
|
1345
|
+
pattern,
|
|
1346
|
+
decision: CODEX_DECISIONS[decision],
|
|
1347
|
+
justification: `${resolved.artifact.key}: ${decision} from ${resolved.origin.name}`
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
/** `.codex/rules/tunestra.rules` in the Starlark format of Codex. */
|
|
1351
|
+
function renderExecRules(rules) {
|
|
1352
|
+
const blocks = rules.map(renderExecRule);
|
|
1353
|
+
return `${[GENERATED_HASH_NOTICE, ...blocks].join("\n\n")}\n`;
|
|
1354
|
+
}
|
|
1355
|
+
/** JSON strings are valid Starlark strings. */
|
|
1356
|
+
function renderExecRule(rule) {
|
|
1357
|
+
const pattern = rule.pattern.map((word) => JSON.stringify(word)).join(", ");
|
|
1358
|
+
const decision = JSON.stringify(rule.decision);
|
|
1359
|
+
const justification = JSON.stringify(rule.justification);
|
|
1360
|
+
return [
|
|
1361
|
+
"prefix_rule(",
|
|
1362
|
+
` pattern = [${pattern}],`,
|
|
1363
|
+
` decision = ${decision},`,
|
|
1364
|
+
` justification = ${justification},`,
|
|
1365
|
+
")"
|
|
1366
|
+
].join("\n");
|
|
1367
|
+
}
|
|
1368
|
+
//#endregion
|
|
1243
1369
|
//#region src/agents/codex/codex-adapter.ts
|
|
1244
1370
|
const CONFIG_FILE = ".codex/config.toml";
|
|
1245
1371
|
const CONFIG_SOURCE = "the rules/ and mcp/ folders and env of tunestra.yaml and its presets";
|
|
1372
|
+
const EXEC_RULES_FILE = ".codex/rules/tunestra.rules";
|
|
1373
|
+
const EXEC_RULES_SOURCE = "permissions in tunestra.yaml and its presets";
|
|
1246
1374
|
const INSTRUCTIONS_INTRO = "Rules of this project, ordered from specific to general. When two rules conflict, follow the one that comes first.";
|
|
1247
|
-
const TRUST_NOTE = "Codex reads .codex/
|
|
1375
|
+
const TRUST_NOTE = "Codex reads .codex/ only in a trusted project: trust the project when Codex asks.";
|
|
1248
1376
|
/**
|
|
1249
1377
|
* Codex reads AGENTS.md and the skills in `.agents/skills` by itself. Rules go to
|
|
1250
1378
|
* `developer_instructions` in `.codex/config.toml`, because Codex does not follow the links
|
|
1251
|
-
* in AGENTS.md; MCP servers go to the same file. Commands become skills
|
|
1379
|
+
* in AGENTS.md; MCP servers and env go to the same file. Commands become skills, and
|
|
1380
|
+
* permission rules for shell commands become `.codex/rules/tunestra.rules`.
|
|
1252
1381
|
*/
|
|
1253
1382
|
const codexAdapter = {
|
|
1254
1383
|
agent: Agent.Codex,
|
|
@@ -1272,18 +1401,67 @@ function planCodex(context) {
|
|
|
1272
1401
|
}
|
|
1273
1402
|
return true;
|
|
1274
1403
|
}), resolution.env);
|
|
1275
|
-
|
|
1404
|
+
const execRules = planExecRules(resolution.permissions);
|
|
1405
|
+
const ignoredPaths = [];
|
|
1406
|
+
notes.push(...execRules.notes);
|
|
1407
|
+
if (typeof configFile === "object") {
|
|
1408
|
+
outputs.push(configFile);
|
|
1409
|
+
ignoredPaths.push(CONFIG_FILE);
|
|
1410
|
+
}
|
|
1411
|
+
if (typeof execRules.file === "object") {
|
|
1412
|
+
outputs.push(execRules.file);
|
|
1413
|
+
ignoredPaths.push(EXEC_RULES_FILE);
|
|
1414
|
+
}
|
|
1415
|
+
if (ignoredPaths.length > 0) notes.push(TRUST_NOTE);
|
|
1416
|
+
return {
|
|
1276
1417
|
outputs,
|
|
1277
|
-
ignoredPaths
|
|
1418
|
+
ignoredPaths,
|
|
1278
1419
|
protectedPaths: [],
|
|
1279
1420
|
notes
|
|
1280
1421
|
};
|
|
1281
|
-
|
|
1282
|
-
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* `deny` and `ask` rules for Bash commands become prefix rules of Codex. Codex has no rules
|
|
1425
|
+
* for files, the web or MCP tools, and `allow` would mean more in Codex, so those are skipped.
|
|
1426
|
+
*/
|
|
1427
|
+
function planExecRules(permissions) {
|
|
1428
|
+
const execRules = [];
|
|
1429
|
+
const skippedRules = [];
|
|
1430
|
+
const allowRules = [];
|
|
1431
|
+
for (const resolved of permissions) {
|
|
1432
|
+
const { key, decision } = resolved.artifact;
|
|
1433
|
+
if (hasCodexDecision(decision) === false) {
|
|
1434
|
+
allowRules.push(key);
|
|
1435
|
+
continue;
|
|
1436
|
+
}
|
|
1437
|
+
const pattern = convertBashRule(key);
|
|
1438
|
+
if (pattern === void 0) {
|
|
1439
|
+
skippedRules.push(key);
|
|
1440
|
+
continue;
|
|
1441
|
+
}
|
|
1442
|
+
const execRule = createExecRule(resolved, decision, pattern);
|
|
1443
|
+
execRules.push(execRule);
|
|
1444
|
+
}
|
|
1445
|
+
const notes = [];
|
|
1446
|
+
const hasSkippedRules = skippedRules.length > 0;
|
|
1447
|
+
const hasAllowRules = allowRules.length > 0;
|
|
1448
|
+
if (hasSkippedRules) {
|
|
1449
|
+
const rules = skippedRules.join(", ");
|
|
1450
|
+
notes.push(`Codex skips permission rules that it cannot enforce: ${rules}. Codex rules match only the start of a shell command.`);
|
|
1451
|
+
}
|
|
1452
|
+
if (hasAllowRules) {
|
|
1453
|
+
const rules = allowRules.join(", ");
|
|
1454
|
+
notes.push(`Codex skips allow rules: ${rules}. In Codex, an allowed command runs outside the sandbox without asking.`);
|
|
1455
|
+
}
|
|
1456
|
+
if (execRules.length > 0) {
|
|
1457
|
+
const content = renderExecRules(execRules);
|
|
1458
|
+
return {
|
|
1459
|
+
file: createFileOutput(EXEC_RULES_FILE, content, EXEC_RULES_SOURCE),
|
|
1460
|
+
notes
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1283
1463
|
return {
|
|
1284
|
-
|
|
1285
|
-
ignoredPaths: [CONFIG_FILE],
|
|
1286
|
-
protectedPaths: [],
|
|
1464
|
+
file: void 0,
|
|
1287
1465
|
notes
|
|
1288
1466
|
};
|
|
1289
1467
|
}
|
|
@@ -1722,6 +1900,62 @@ function orderFromSpecific(levels, inherited, selectArtifacts) {
|
|
|
1722
1900
|
return ordered;
|
|
1723
1901
|
}
|
|
1724
1902
|
//#endregion
|
|
1903
|
+
//#region src/inheritance/inherit-permissions.ts
|
|
1904
|
+
/**
|
|
1905
|
+
* Permission lists are joined along the chain: every level adds its rules. A more specific
|
|
1906
|
+
* level can remove inherited `allow` and `ask` rules with `exclude.permissions`, but not
|
|
1907
|
+
* `deny` rules, so that a general level keeps a floor of what is never allowed.
|
|
1908
|
+
*/
|
|
1909
|
+
function inheritPermissions(levels) {
|
|
1910
|
+
const inherited = /* @__PURE__ */ new Map();
|
|
1911
|
+
const problems = [];
|
|
1912
|
+
for (const level of levels) {
|
|
1913
|
+
const excludeProblems = excludeRules(inherited, level);
|
|
1914
|
+
const origin = createOrigin(level);
|
|
1915
|
+
problems.push(...excludeProblems);
|
|
1916
|
+
for (const rule of level.permissions) {
|
|
1917
|
+
const id = buildRuleId(rule);
|
|
1918
|
+
const previous = inherited.get(id);
|
|
1919
|
+
const overrides = previous === void 0 ? [] : [...previous.overrides, previous.origin];
|
|
1920
|
+
inherited.set(id, {
|
|
1921
|
+
artifact: rule,
|
|
1922
|
+
origin,
|
|
1923
|
+
overrides,
|
|
1924
|
+
patchedBy: []
|
|
1925
|
+
});
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
return {
|
|
1929
|
+
artifacts: [...inherited.values()],
|
|
1930
|
+
problems
|
|
1931
|
+
};
|
|
1932
|
+
}
|
|
1933
|
+
/** The same rule can be in two lists, for example in `allow` on one level and `deny` on another. */
|
|
1934
|
+
function buildRuleId(rule) {
|
|
1935
|
+
return `${rule.decision} ${rule.key}`;
|
|
1936
|
+
}
|
|
1937
|
+
function excludeRules(inherited, level) {
|
|
1938
|
+
const keys = level.exclude.permissions ?? [];
|
|
1939
|
+
const problems = [];
|
|
1940
|
+
for (const key of keys) {
|
|
1941
|
+
const matches = [...inherited.values()].filter((resolved) => resolved.artifact.key === key);
|
|
1942
|
+
const deny = matches.find((resolved) => resolved.artifact.decision === PermissionDecision.Deny);
|
|
1943
|
+
if (typeof deny === "object") {
|
|
1944
|
+
problems.push(`${level.name}: exclude.permissions lists "${key}", but it is a deny rule of ${deny.origin.name}, and a deny rule cannot be removed`);
|
|
1945
|
+
continue;
|
|
1946
|
+
}
|
|
1947
|
+
if (matches.length === 0) {
|
|
1948
|
+
problems.push(`${level.name}: exclude.permissions lists "${key}", but no more general level has it`);
|
|
1949
|
+
continue;
|
|
1950
|
+
}
|
|
1951
|
+
for (const match of matches) {
|
|
1952
|
+
const id = buildRuleId(match.artifact);
|
|
1953
|
+
inherited.delete(id);
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
return problems;
|
|
1957
|
+
}
|
|
1958
|
+
//#endregion
|
|
1725
1959
|
//#region src/shared/utils/records.ts
|
|
1726
1960
|
/** Merges two string maps; keys of `patch` win. Missing on both sides stays missing. */
|
|
1727
1961
|
function mergeRecords(base, patch) {
|
|
@@ -1791,12 +2025,14 @@ function applyInheritance(chain) {
|
|
|
1791
2025
|
const commands = inheritArtifacts(chain.levels, ArtifactKind.Commands, (level) => level.commands);
|
|
1792
2026
|
const mcp = inheritArtifacts(chain.levels, ArtifactKind.Mcp, (level) => level.mcp, patchMcpServers);
|
|
1793
2027
|
const env = inheritArtifacts(chain.levels, ArtifactKind.Env, (level) => level.env);
|
|
2028
|
+
const permissions = inheritPermissions(chain.levels);
|
|
1794
2029
|
const problems = [
|
|
1795
2030
|
...rules.problems,
|
|
1796
2031
|
...skills.problems,
|
|
1797
2032
|
...commands.problems,
|
|
1798
2033
|
...mcp.problems,
|
|
1799
|
-
...env.problems
|
|
2034
|
+
...env.problems,
|
|
2035
|
+
...permissions.problems
|
|
1800
2036
|
];
|
|
1801
2037
|
if (problems.length > 0) throw createProblemsError(problems);
|
|
1802
2038
|
return {
|
|
@@ -1805,7 +2041,8 @@ function applyInheritance(chain) {
|
|
|
1805
2041
|
skills: skills.artifacts,
|
|
1806
2042
|
commands: commands.artifacts,
|
|
1807
2043
|
mcp: mcp.artifacts,
|
|
1808
|
-
env: env.artifacts
|
|
2044
|
+
env: env.artifacts,
|
|
2045
|
+
permissions: permissions.artifacts
|
|
1809
2046
|
};
|
|
1810
2047
|
}
|
|
1811
2048
|
//#endregion
|
|
@@ -2157,14 +2394,28 @@ async function runSetup(projectDir, options) {
|
|
|
2157
2394
|
/** For --verbose: every artifact, the level it comes from and the file to edit. */
|
|
2158
2395
|
function describeResolution(resolution) {
|
|
2159
2396
|
const projectDir = resolution.chain.project.dir;
|
|
2397
|
+
const permissions = resolution.permissions.map(labelPermission);
|
|
2160
2398
|
return [
|
|
2161
2399
|
describeSection("Rules", resolution.rules, projectDir),
|
|
2162
2400
|
describeSection("Skills", resolution.skills, projectDir),
|
|
2163
2401
|
describeSection("Commands", resolution.commands, projectDir),
|
|
2164
2402
|
describeSection("MCP servers", resolution.mcp, projectDir),
|
|
2165
|
-
describeSection("Environment", resolution.env, projectDir)
|
|
2403
|
+
describeSection("Environment", resolution.env, projectDir),
|
|
2404
|
+
describeSection("Permissions", permissions, projectDir)
|
|
2166
2405
|
].filter((section) => section.length > 0).join("\n\n");
|
|
2167
2406
|
}
|
|
2407
|
+
/** For example `deny Read(./.env)`: the same rule can be in two lists. */
|
|
2408
|
+
function labelPermission(resolved) {
|
|
2409
|
+
const { decision, key } = resolved.artifact;
|
|
2410
|
+
const artifact = {
|
|
2411
|
+
...resolved.artifact,
|
|
2412
|
+
key: `${decision} ${key}`
|
|
2413
|
+
};
|
|
2414
|
+
return {
|
|
2415
|
+
...resolved,
|
|
2416
|
+
artifact
|
|
2417
|
+
};
|
|
2418
|
+
}
|
|
2168
2419
|
function describeSection(title, artifacts, projectDir) {
|
|
2169
2420
|
if (artifacts.length === 0) return "";
|
|
2170
2421
|
const rows = artifacts.map((resolved) => {
|
package/dist/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { t as VERSION } from "./src-
|
|
1
|
+
export { t as VERSION } from "./src-Bh7d7ERC.mjs";
|
package/package.json
CHANGED