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 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 and environment variables, and generates each agent's native
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 it | yes |
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 `env` is replaced with a warning, for example in a
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. It ignores some variables in
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`, then permissions, subagents and a personal `local` level |
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-BNeh64fK.mjs";
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 = ["$schema", "env"];
1059
- function renderSettingsJson(env) {
1060
- const variables = buildEnvMap(env);
1061
- return `${JSON.stringify({
1062
- $schema: SETTINGS_SCHEMA,
1063
- env: variables
1064
- }, null, 2)}\n`;
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; `settings.local.json`
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(env);
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/config.toml only in a trusted project: trust the project when Codex asks.";
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
- if (configFile === void 0) return {
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
- outputs.push(configFile);
1282
- notes.push(TRUST_NOTE);
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
- outputs,
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-BNeh64fK.mjs";
1
+ export { t as VERSION } from "./src-Bh7d7ERC.mjs";
@@ -1,5 +1,5 @@
1
1
  //#endregion
2
2
  //#region src/index.ts
3
- const VERSION = "0.3.0";
3
+ const VERSION = "0.4.0";
4
4
  //#endregion
5
5
  export { VERSION as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tunestra",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Tune your AI agents to play as one orchestra: one source of rules, skills, commands and MCP servers for Claude Code, Codex and more.",
5
5
  "keywords": [
6
6
  "ai",