@nathapp/nax 0.42.8 → 0.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.43.0] - 2026-03-16
9
+
10
+ ### Added
11
+ - **PERM-001:** `src/config/permissions.ts` — `resolvePermissions(config, stage)` as the single source of truth for all permission decisions across CLI and ACP adapters.
12
+ - **New types:** `PermissionProfile` (`"unrestricted" | "safe" | "scoped"`), `PipelineStage`, `ResolvedPermissions` interface.
13
+ - **Schema:** `execution.permissionProfile` config field — takes precedence over legacy `dangerouslySkipPermissions` boolean. `"scoped"` is a Phase 2 stub.
14
+ - **`pipelineStage?`** added to `AgentRunOptions` — each call site sets the appropriate stage (`"plan"`, `"run"`, `"rectification"`, etc.).
15
+ - **`config?`** added to `CompleteOptions` — all `complete()` call sites now thread config so permissions are resolved correctly.
16
+
17
+ ### Fixed
18
+ - **Hardcoded `--dangerously-skip-permissions`** in `claude-plan.ts` — now resolved from config.
19
+ - **`?? false` fallback** in `plan.ts` — removed; replaced with `resolvePermissions()`.
20
+ - **`?? true` fallback** in `claude-execution.ts` — removed; replaced with `resolvePermissions()`.
21
+ - **`resolvePermissions(undefined, ...)` in ACP `complete()`** — now passes `_options?.config`.
22
+ - All ACP adapter permission ternaries replaced with `resolvePermissions()`.
23
+
24
+ ### Changed
25
+ - `nax/config.json` — explicit `"permissionProfile": "unrestricted"` (was implicit via schema default).
26
+
8
27
  ## [0.30.0] - 2026-03-08
9
28
 
10
29
  ### Fixed
package/bin/nax.ts CHANGED
@@ -656,6 +656,14 @@ program
656
656
  // Load config
657
657
  const config = await loadConfig(workdir);
658
658
 
659
+ // Initialize logger — writes to nax/features/<feature>/plan-<timestamp>.jsonl
660
+ const featureLogDir = join(naxDir, "features", options.feature);
661
+ mkdirSync(featureLogDir, { recursive: true });
662
+ const planLogId = new Date().toISOString().replace(/:/g, "-").replace(/\..+/, "");
663
+ const planLogPath = join(featureLogDir, `plan-${planLogId}.jsonl`);
664
+ initLogger({ level: "info", filePath: planLogPath, useChalk: false, headless: true });
665
+ console.log(chalk.dim(` [Plan log: ${planLogPath}]`));
666
+
659
667
  try {
660
668
  const prdPath = await planCommand(workdir, config, {
661
669
  from: options.from,
@@ -666,6 +674,7 @@ program
666
674
 
667
675
  console.log(chalk.green("\n[OK] PRD generated"));
668
676
  console.log(chalk.dim(` PRD: ${prdPath}`));
677
+ console.log(chalk.dim(` Log: ${planLogPath}`));
669
678
  console.log(chalk.dim(`\nNext: nax run -f ${options.feature}`));
670
679
  } catch (err) {
671
680
  console.error(chalk.red(`Error: ${(err as Error).message}`));
package/dist/nax.js CHANGED
@@ -2552,6 +2552,24 @@ var require_commander = __commonJS((exports) => {
2552
2552
  exports.InvalidOptionArgumentError = InvalidArgumentError;
2553
2553
  });
2554
2554
 
2555
+ // src/config/permissions.ts
2556
+ function resolvePermissions(config, _stage) {
2557
+ const profile = config?.execution?.permissionProfile ?? (config?.execution?.dangerouslySkipPermissions ? "unrestricted" : "safe");
2558
+ switch (profile) {
2559
+ case "unrestricted":
2560
+ return { mode: "approve-all", skipPermissions: true };
2561
+ case "safe":
2562
+ return { mode: "approve-reads", skipPermissions: false };
2563
+ case "scoped":
2564
+ return resolveScopedPermissions(config, _stage);
2565
+ default:
2566
+ return { mode: "approve-reads", skipPermissions: false };
2567
+ }
2568
+ }
2569
+ function resolveScopedPermissions(_config, _stage) {
2570
+ return { mode: "approve-reads", skipPermissions: false };
2571
+ }
2572
+
2555
2573
  // src/logging/types.ts
2556
2574
  var EMOJI;
2557
2575
  var init_types = __esm(() => {
@@ -3244,6 +3262,10 @@ async function executeComplete(binary, prompt, options) {
3244
3262
  if (options?.jsonMode) {
3245
3263
  cmd.push("--output-format", "json");
3246
3264
  }
3265
+ const { skipPermissions } = resolvePermissions(options?.config, "complete");
3266
+ if (skipPermissions) {
3267
+ cmd.push("--dangerously-skip-permissions");
3268
+ }
3247
3269
  const spawnOpts = { stdout: "pipe", stderr: "pipe" };
3248
3270
  if (options?.workdir)
3249
3271
  spawnOpts.cwd = options.workdir;
@@ -3501,7 +3523,7 @@ var init_cost = __esm(() => {
3501
3523
  // src/agents/claude-execution.ts
3502
3524
  function buildCommand(binary, options) {
3503
3525
  const model = options.modelDef.model;
3504
- const skipPermissions = options.dangerouslySkipPermissions ?? true;
3526
+ const { skipPermissions } = resolvePermissions(options.config, options.pipelineStage ?? "run");
3505
3527
  const permArgs = skipPermissions ? ["--dangerously-skip-permissions"] : [];
3506
3528
  return [binary, "--model", model, ...permArgs, "-p", options.prompt];
3507
3529
  }
@@ -17619,6 +17641,12 @@ var init_schemas3 = __esm(() => {
17619
17641
  lintCommand: exports_external.string().nullable().optional(),
17620
17642
  typecheckCommand: exports_external.string().nullable().optional(),
17621
17643
  dangerouslySkipPermissions: exports_external.boolean().default(true),
17644
+ permissionProfile: exports_external.enum(["unrestricted", "safe", "scoped"]).optional(),
17645
+ permissions: exports_external.record(exports_external.string(), exports_external.object({
17646
+ mode: exports_external.enum(["approve-all", "approve-reads", "scoped"]),
17647
+ allowedTools: exports_external.array(exports_external.string()).optional(),
17648
+ inherit: exports_external.string().optional()
17649
+ })).optional(),
17622
17650
  smartTestRunner: smartTestRunnerFieldSchema
17623
17651
  });
17624
17652
  QualityConfigSchema = exports_external.object({
@@ -18104,7 +18132,10 @@ function buildPlanCommand(binary, options) {
18104
18132
  if (modelDef) {
18105
18133
  cmd.push("--model", modelDef.model);
18106
18134
  }
18107
- cmd.push("--dangerously-skip-permissions");
18135
+ const { skipPermissions } = resolvePermissions(options.config, "plan");
18136
+ if (skipPermissions) {
18137
+ cmd.push("--dangerously-skip-permissions");
18138
+ }
18108
18139
  let fullPrompt = options.prompt;
18109
18140
  if (options.codebaseContext) {
18110
18141
  fullPrompt = `${options.codebaseContext}
@@ -18306,7 +18337,11 @@ class ClaudeCodeAdapter {
18306
18337
  }
18307
18338
  modelDef = resolveBalancedModelDef2(options.config);
18308
18339
  }
18309
- const cmd = [this.binary, "--model", modelDef.model, "--dangerously-skip-permissions", "-p", prompt];
18340
+ const { skipPermissions } = resolvePermissions(options.config, "run");
18341
+ const cmd = [this.binary, "--model", modelDef.model, "-p", prompt];
18342
+ if (skipPermissions) {
18343
+ cmd.splice(cmd.length - 2, 0, "--dangerously-skip-permissions");
18344
+ }
18310
18345
  const pidRegistry = this.getPidRegistry(options.workdir);
18311
18346
  const proc = _decomposeDeps.spawn(cmd, {
18312
18347
  cwd: options.workdir,
@@ -18497,7 +18532,8 @@ async function refineAcceptanceCriteria(criteria, context) {
18497
18532
  response = await _refineDeps.adapter.complete(prompt, {
18498
18533
  jsonMode: true,
18499
18534
  maxTokens: 4096,
18500
- model: modelDef.model
18535
+ model: modelDef.model,
18536
+ config: config2
18501
18537
  });
18502
18538
  } catch (error48) {
18503
18539
  const reason = errorMessage(error48);
@@ -18579,7 +18615,7 @@ describe("${options.featureName} - Acceptance Tests", () => {
18579
18615
 
18580
18616
  Respond with ONLY the TypeScript test code (no markdown code fences, no explanation).`;
18581
18617
  logger.info("acceptance", "Generating tests from PRD refined criteria", { count: refinedCriteria.length });
18582
- const testCode = await _generatorPRDDeps.adapter.complete(prompt);
18618
+ const testCode = await _generatorPRDDeps.adapter.complete(prompt, { config: options.config });
18583
18619
  const refinedJsonContent = JSON.stringify(refinedCriteria.map((c, i) => ({
18584
18620
  acId: `AC-${i + 1}`,
18585
18621
  original: c.original,
@@ -18702,7 +18738,8 @@ async function generateAcceptanceTests(adapter, options) {
18702
18738
  const prompt = buildAcceptanceTestPrompt(criteria, options.featureName, options.codebaseContext);
18703
18739
  try {
18704
18740
  const output = await adapter.complete(prompt, {
18705
- model: options.modelDef.model
18741
+ model: options.modelDef.model,
18742
+ config: options.config
18706
18743
  });
18707
18744
  const testCode = extractTestCode(output);
18708
18745
  return {
@@ -18823,7 +18860,8 @@ async function generateFixStories(adapter, options) {
18823
18860
  const prompt = buildFixPrompt(failedAC, acText, testOutput, relatedStories, prd);
18824
18861
  try {
18825
18862
  const fixDescription = await adapter.complete(prompt, {
18826
- model: modelDef.model
18863
+ model: modelDef.model,
18864
+ config: options.config
18827
18865
  });
18828
18866
  fixStories.push({
18829
18867
  id: `US-FIX-${String(i + 1).padStart(3, "0")}`,
@@ -19120,7 +19158,7 @@ class SpawnAcpClient {
19120
19158
  pidRegistry: this.pidRegistry
19121
19159
  });
19122
19160
  }
19123
- async loadSession(sessionName, agentName) {
19161
+ async loadSession(sessionName, agentName, permissionMode) {
19124
19162
  const cmd = ["acpx", "--cwd", this.cwd, agentName, "sessions", "ensure", "--name", sessionName];
19125
19163
  const proc = _spawnClientDeps.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
19126
19164
  const exitCode = await proc.exited;
@@ -19133,7 +19171,7 @@ class SpawnAcpClient {
19133
19171
  cwd: this.cwd,
19134
19172
  model: this.model,
19135
19173
  timeoutSeconds: this.timeoutSeconds,
19136
- permissionMode: this.permissionMode,
19174
+ permissionMode,
19137
19175
  env: this.env,
19138
19176
  pidRegistry: this.pidRegistry
19139
19177
  });
@@ -19224,7 +19262,7 @@ async function ensureAcpSession(client, sessionName, agentName, permissionMode)
19224
19262
  }
19225
19263
  if (client.loadSession) {
19226
19264
  try {
19227
- const existing = await client.loadSession(sessionName, agentName);
19265
+ const existing = await client.loadSession(sessionName, agentName, permissionMode);
19228
19266
  if (existing) {
19229
19267
  getSafeLogger()?.debug("acp-adapter", `Resumed existing session: ${sessionName}`);
19230
19268
  return existing;
@@ -19409,11 +19447,11 @@ class AcpAgentAdapter {
19409
19447
  sessionName = await readAcpSession(options.workdir, options.featureName, options.storyId) ?? undefined;
19410
19448
  }
19411
19449
  sessionName ??= buildSessionName(options.workdir, options.featureName, options.storyId, options.sessionRole);
19412
- const permissionMode = options.dangerouslySkipPermissions ? "approve-all" : "approve-reads";
19450
+ const resolvedPerm = resolvePermissions(options.config, options.pipelineStage ?? "run");
19451
+ const permissionMode = resolvedPerm.mode;
19413
19452
  getSafeLogger()?.info("acp-adapter", "Permission mode resolved", {
19414
19453
  permission: permissionMode,
19415
- dangerouslySkipPermissions: options.dangerouslySkipPermissions ?? false,
19416
- stage: options.featureName ? "run" : "plan"
19454
+ stage: options.pipelineStage ?? "run"
19417
19455
  });
19418
19456
  const session = await ensureAcpSession(client, sessionName, this.name, permissionMode);
19419
19457
  if (options.featureName && options.storyId) {
@@ -19494,7 +19532,7 @@ class AcpAgentAdapter {
19494
19532
  async complete(prompt, _options) {
19495
19533
  const model = _options?.model ?? "default";
19496
19534
  const timeoutMs = _options?.timeoutMs ?? 120000;
19497
- const permissionMode = _options?.dangerouslySkipPermissions ? "approve-all" : "default";
19535
+ const permissionMode = resolvePermissions(_options?.config, "complete").mode;
19498
19536
  const workdir = _options?.workdir;
19499
19537
  let lastError;
19500
19538
  for (let attempt = 0;attempt < MAX_RATE_LIMIT_RETRIES; attempt++) {
@@ -19574,7 +19612,9 @@ class AcpAgentAdapter {
19574
19612
  modelTier: options.modelTier ?? "balanced",
19575
19613
  modelDef,
19576
19614
  timeoutSeconds,
19577
- dangerouslySkipPermissions: options.dangerouslySkipPermissions ?? false,
19615
+ dangerouslySkipPermissions: resolvePermissions(options.config, "plan").skipPermissions,
19616
+ pipelineStage: "plan",
19617
+ config: options.config,
19578
19618
  interactionBridge: options.interactionBridge,
19579
19619
  maxInteractionTurns: options.maxInteractionTurns,
19580
19620
  featureName: options.featureName,
@@ -19596,7 +19636,11 @@ class AcpAgentAdapter {
19596
19636
  const prompt = buildDecomposePrompt(options);
19597
19637
  let output;
19598
19638
  try {
19599
- output = await this.complete(prompt, { model, jsonMode: true });
19639
+ output = await this.complete(prompt, {
19640
+ model,
19641
+ jsonMode: true,
19642
+ config: options.config
19643
+ });
19600
19644
  } catch (err) {
19601
19645
  const msg = err instanceof Error ? err.message : String(err);
19602
19646
  throw new Error(`[acp-adapter] decompose() failed: ${msg}`, { cause: err });
@@ -20854,7 +20898,7 @@ async function callLlmOnce(adapter, modelTier, prompt, config2, timeoutMs) {
20854
20898
  }, timeoutMs);
20855
20899
  });
20856
20900
  timeoutPromise.catch(() => {});
20857
- const outputPromise = adapter.complete(prompt, { model: modelArg });
20901
+ const outputPromise = adapter.complete(prompt, { model: modelArg, config: config2 });
20858
20902
  try {
20859
20903
  const result = await Promise.race([outputPromise, timeoutPromise]);
20860
20904
  clearTimeout(timeoutId);
@@ -21924,7 +21968,7 @@ var package_default;
21924
21968
  var init_package = __esm(() => {
21925
21969
  package_default = {
21926
21970
  name: "@nathapp/nax",
21927
- version: "0.42.8",
21971
+ version: "0.43.0",
21928
21972
  description: "AI Coding Agent Orchestrator \u2014 loops until done",
21929
21973
  type: "module",
21930
21974
  bin: {
@@ -21997,8 +22041,8 @@ var init_version = __esm(() => {
21997
22041
  NAX_VERSION = package_default.version;
21998
22042
  NAX_COMMIT = (() => {
21999
22043
  try {
22000
- if (/^[0-9a-f]{6,10}$/.test("5dc5b37"))
22001
- return "5dc5b37";
22044
+ if (/^[0-9a-f]{6,10}$/.test("d725ea0"))
22045
+ return "d725ea0";
22002
22046
  } catch {}
22003
22047
  try {
22004
22048
  const result = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
@@ -23230,7 +23274,8 @@ class AutoInteractionPlugin {
23230
23274
  }
23231
23275
  const output = await adapter.complete(prompt, {
23232
23276
  ...modelArg && { model: modelArg },
23233
- jsonMode: true
23277
+ jsonMode: true,
23278
+ ...this.config.naxConfig && { config: this.config.naxConfig }
23234
23279
  });
23235
23280
  return this.parseResponse(output);
23236
23281
  }
@@ -26315,7 +26360,9 @@ async function runRectificationLoop(story, config2, workdir, agent, implementerT
26315
26360
  modelTier: implementerTier,
26316
26361
  modelDef: resolveModel(config2.models[implementerTier]),
26317
26362
  timeoutSeconds: config2.execution.sessionTimeoutSeconds,
26318
- dangerouslySkipPermissions: config2.execution.dangerouslySkipPermissions,
26363
+ dangerouslySkipPermissions: resolvePermissions(config2, "rectification").skipPermissions,
26364
+ pipelineStage: "rectification",
26365
+ config: config2,
26319
26366
  maxInteractionTurns: config2.agent?.maxInteractionTurns,
26320
26367
  featureName,
26321
26368
  storyId: story.id,
@@ -26923,7 +26970,9 @@ async function runTddSession(role, agent, story, config2, workdir, modelTier, be
26923
26970
  modelTier,
26924
26971
  modelDef: resolveModel(config2.models[modelTier]),
26925
26972
  timeoutSeconds: config2.execution.sessionTimeoutSeconds,
26926
- dangerouslySkipPermissions: config2.execution.dangerouslySkipPermissions,
26973
+ dangerouslySkipPermissions: resolvePermissions(config2, "run").skipPermissions,
26974
+ pipelineStage: "run",
26975
+ config: config2,
26927
26976
  maxInteractionTurns: config2.agent?.maxInteractionTurns,
26928
26977
  featureName,
26929
26978
  storyId: story.id,
@@ -27673,7 +27722,9 @@ Category: ${tddResult.failureCategory ?? "unknown"}`,
27673
27722
  modelTier: ctx.routing.modelTier,
27674
27723
  modelDef: resolveModel(ctx.config.models[ctx.routing.modelTier]),
27675
27724
  timeoutSeconds: ctx.config.execution.sessionTimeoutSeconds,
27676
- dangerouslySkipPermissions: ctx.config.execution.dangerouslySkipPermissions,
27725
+ dangerouslySkipPermissions: resolvePermissions(ctx.config, "run").skipPermissions,
27726
+ pipelineStage: "run",
27727
+ config: ctx.config,
27677
27728
  maxInteractionTurns: ctx.config.agent?.maxInteractionTurns,
27678
27729
  pidRegistry: ctx.pidRegistry,
27679
27730
  featureName: ctx.prd.feature,
@@ -28223,7 +28274,9 @@ ${rectificationPrompt}`;
28223
28274
  modelTier,
28224
28275
  modelDef,
28225
28276
  timeoutSeconds: config2.execution.sessionTimeoutSeconds,
28226
- dangerouslySkipPermissions: config2.execution.dangerouslySkipPermissions,
28277
+ dangerouslySkipPermissions: resolvePermissions(config2, "rectification").skipPermissions,
28278
+ pipelineStage: "rectification",
28279
+ config: config2,
28227
28280
  maxInteractionTurns: config2.agent?.maxInteractionTurns
28228
28281
  });
28229
28282
  if (agentResult.success) {
@@ -28944,7 +28997,7 @@ async function runDecompose(story, prd, config2, _workdir, agentGetFn) {
28944
28997
  }
28945
28998
  const adapter = {
28946
28999
  async decompose(prompt) {
28947
- return agent.complete(prompt, { jsonMode: true });
29000
+ return agent.complete(prompt, { jsonMode: true, config: config2 });
28948
29001
  }
28949
29002
  };
28950
29003
  return DecomposeBuilder.for(story).prd(prd).config(builderConfig).decompose(adapter);
@@ -65845,7 +65898,7 @@ async function planCommand(workdir, config2, options) {
65845
65898
  const cliAdapter = _deps2.getAgent(agentName);
65846
65899
  if (!cliAdapter)
65847
65900
  throw new Error(`[plan] No agent adapter found for '${agentName}'`);
65848
- rawResponse = await cliAdapter.complete(prompt, { jsonMode: true, workdir });
65901
+ rawResponse = await cliAdapter.complete(prompt, { jsonMode: true, workdir, config: config2 });
65849
65902
  try {
65850
65903
  const envelope = JSON.parse(rawResponse);
65851
65904
  if (envelope?.type === "result" && typeof envelope?.result === "string") {
@@ -65859,13 +65912,12 @@ async function planCommand(workdir, config2, options) {
65859
65912
  throw new Error(`[plan] No agent adapter found for '${agentName}'`);
65860
65913
  const interactionBridge = createCliInteractionBridge();
65861
65914
  const pidRegistry = new PidRegistry(workdir);
65862
- const dangerouslySkipPermissions = config2?.execution?.dangerouslySkipPermissions ?? false;
65863
- const permissionMode = dangerouslySkipPermissions ? "approve-all" : "approve-reads";
65915
+ const resolvedPerm = resolvePermissions(config2, "plan");
65864
65916
  const resolvedModel = config2?.plan?.model ?? "balanced";
65865
65917
  logger?.info("plan", "Starting interactive planning session", {
65866
65918
  agent: agentName,
65867
65919
  model: resolvedModel,
65868
- permission: permissionMode,
65920
+ permission: resolvedPerm.mode,
65869
65921
  workdir,
65870
65922
  feature: options.feature,
65871
65923
  timeoutSeconds
@@ -65880,7 +65932,7 @@ async function planCommand(workdir, config2, options) {
65880
65932
  interactionBridge,
65881
65933
  config: config2,
65882
65934
  modelTier: resolvedModel,
65883
- dangerouslySkipPermissions,
65935
+ dangerouslySkipPermissions: resolvedPerm.skipPermissions,
65884
65936
  maxInteractionTurns: config2?.agent?.maxInteractionTurns,
65885
65937
  featureName: options.feature,
65886
65938
  pidRegistry
@@ -76996,6 +77048,12 @@ Use: nax plan -f <feature> --from <spec>`));
76996
77048
  process.exit(1);
76997
77049
  }
76998
77050
  const config2 = await loadConfig(workdir);
77051
+ const featureLogDir = join43(naxDir, "features", options.feature);
77052
+ mkdirSync6(featureLogDir, { recursive: true });
77053
+ const planLogId = new Date().toISOString().replace(/:/g, "-").replace(/\..+/, "");
77054
+ const planLogPath = join43(featureLogDir, `plan-${planLogId}.jsonl`);
77055
+ initLogger({ level: "info", filePath: planLogPath, useChalk: false, headless: true });
77056
+ console.log(source_default.dim(` [Plan log: ${planLogPath}]`));
76999
77057
  try {
77000
77058
  const prdPath = await planCommand(workdir, config2, {
77001
77059
  from: options.from,
@@ -77006,6 +77064,7 @@ Use: nax plan -f <feature> --from <spec>`));
77006
77064
  console.log(source_default.green(`
77007
77065
  [OK] PRD generated`));
77008
77066
  console.log(source_default.dim(` PRD: ${prdPath}`));
77067
+ console.log(source_default.dim(` Log: ${planLogPath}`));
77009
77068
  console.log(source_default.dim(`
77010
77069
  Next: nax run -f ${options.feature}`));
77011
77070
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nathapp/nax",
3
- "version": "0.42.8",
3
+ "version": "0.43.0",
4
4
  "description": "AI Coding Agent Orchestrator — loops until done",
5
5
  "type": "module",
6
6
  "bin": {
@@ -228,6 +228,7 @@ export async function generateFixStories(
228
228
  // Call adapter to generate fix description
229
229
  const fixDescription = await adapter.complete(prompt, {
230
230
  model: modelDef.model,
231
+ config: options.config,
231
232
  });
232
233
 
233
234
  fixStories.push({
@@ -110,7 +110,7 @@ Respond with ONLY the TypeScript test code (no markdown code fences, no explanat
110
110
 
111
111
  logger.info("acceptance", "Generating tests from PRD refined criteria", { count: refinedCriteria.length });
112
112
 
113
- const testCode = await _generatorPRDDeps.adapter.complete(prompt);
113
+ const testCode = await _generatorPRDDeps.adapter.complete(prompt, { config: options.config });
114
114
 
115
115
  const refinedJsonContent = JSON.stringify(
116
116
  refinedCriteria.map((c, i) => ({
@@ -298,6 +298,7 @@ export async function generateAcceptanceTests(
298
298
  // Call adapter to generate tests
299
299
  const output = await adapter.complete(prompt, {
300
300
  model: options.modelDef.model,
301
+ config: options.config,
301
302
  });
302
303
 
303
304
  // Extract test code from output
@@ -192,6 +192,7 @@ export async function refineAcceptanceCriteria(
192
192
  jsonMode: true,
193
193
  maxTokens: 4096,
194
194
  model: modelDef.model,
195
+ config,
195
196
  });
196
197
  } catch (error) {
197
198
  const reason = errorMessage(error);
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { createHash } from "node:crypto";
15
15
  import { join } from "node:path";
16
+ import { resolvePermissions } from "../../config/permissions";
16
17
  import { getSafeLogger } from "../../logger";
17
18
  import { buildDecomposePrompt, parseDecomposeOutput } from "../claude-decompose";
18
19
  import { createSpawnAcpClient } from "./spawn-client";
@@ -92,7 +93,7 @@ export interface AcpClient {
92
93
  start(): Promise<void>;
93
94
  createSession(opts: { agentName: string; permissionMode: string; sessionName?: string }): Promise<AcpSession>;
94
95
  /** Resume an existing named session. Returns null if the session is not found. */
95
- loadSession?(sessionName: string, agentName: string): Promise<AcpSession | null>;
96
+ loadSession?(sessionName: string, agentName: string, permissionMode: string): Promise<AcpSession | null>;
96
97
  close(): Promise<void>;
97
98
  }
98
99
 
@@ -192,7 +193,7 @@ export async function ensureAcpSession(
192
193
  // Try to resume existing session first
193
194
  if (client.loadSession) {
194
195
  try {
195
- const existing = await client.loadSession(sessionName, agentName);
196
+ const existing = await client.loadSession(sessionName, agentName, permissionMode);
196
197
  if (existing) {
197
198
  getSafeLogger()?.debug("acp-adapter", `Resumed existing session: ${sessionName}`);
198
199
  return existing;
@@ -451,12 +452,12 @@ export class AcpAgentAdapter implements AgentAdapter {
451
452
  }
452
453
  sessionName ??= buildSessionName(options.workdir, options.featureName, options.storyId, options.sessionRole);
453
454
 
454
- // 2. Permission mode follows dangerouslySkipPermissions, default is "approve-reads". or should --deny-all be the default?
455
- const permissionMode = options.dangerouslySkipPermissions ? "approve-all" : "approve-reads";
455
+ // 2. Resolve permission mode from config via single source of truth.
456
+ const resolvedPerm = resolvePermissions(options.config, options.pipelineStage ?? "run");
457
+ const permissionMode = resolvedPerm.mode;
456
458
  getSafeLogger()?.info("acp-adapter", "Permission mode resolved", {
457
459
  permission: permissionMode,
458
- dangerouslySkipPermissions: options.dangerouslySkipPermissions ?? false,
459
- stage: options.featureName ? "run" : "plan",
460
+ stage: options.pipelineStage ?? "run",
460
461
  });
461
462
 
462
463
  // 3. Ensure session (resume existing or create new)
@@ -567,7 +568,7 @@ export class AcpAgentAdapter implements AgentAdapter {
567
568
  async complete(prompt: string, _options?: CompleteOptions): Promise<string> {
568
569
  const model = _options?.model ?? "default";
569
570
  const timeoutMs = _options?.timeoutMs ?? 120_000; // 2-min safety net by default
570
- const permissionMode = _options?.dangerouslySkipPermissions ? "approve-all" : "default";
571
+ const permissionMode = resolvePermissions(_options?.config, "complete").mode;
571
572
  const workdir = _options?.workdir;
572
573
 
573
574
  let lastError: Error | undefined;
@@ -677,7 +678,12 @@ export class AcpAgentAdapter implements AgentAdapter {
677
678
  modelTier: options.modelTier ?? "balanced",
678
679
  modelDef,
679
680
  timeoutSeconds,
680
- dangerouslySkipPermissions: options.dangerouslySkipPermissions ?? false,
681
+ dangerouslySkipPermissions: resolvePermissions(
682
+ options.config as import("../../config").NaxConfig | undefined,
683
+ "plan",
684
+ ).skipPermissions,
685
+ pipelineStage: "plan",
686
+ config: options.config as import("../../config").NaxConfig | undefined,
681
687
  interactionBridge: options.interactionBridge,
682
688
  maxInteractionTurns: options.maxInteractionTurns,
683
689
  featureName: options.featureName,
@@ -704,7 +710,11 @@ export class AcpAgentAdapter implements AgentAdapter {
704
710
 
705
711
  let output: string;
706
712
  try {
707
- output = await this.complete(prompt, { model, jsonMode: true });
713
+ output = await this.complete(prompt, {
714
+ model,
715
+ jsonMode: true,
716
+ config: options.config as import("../../config").NaxConfig | undefined,
717
+ });
708
718
  } catch (err) {
709
719
  const msg = err instanceof Error ? err.message : String(err);
710
720
  throw new Error(`[acp-adapter] decompose() failed: ${msg}`, { cause: err });
@@ -316,7 +316,7 @@ export class SpawnAcpClient implements AcpClient {
316
316
  });
317
317
  }
318
318
 
319
- async loadSession(sessionName: string, agentName: string): Promise<AcpSession | null> {
319
+ async loadSession(sessionName: string, agentName: string, permissionMode: string): Promise<AcpSession | null> {
320
320
  // Try to ensure session exists — if it does, acpx returns success
321
321
  const cmd = ["acpx", "--cwd", this.cwd, agentName, "sessions", "ensure", "--name", sessionName];
322
322
 
@@ -333,7 +333,7 @@ export class SpawnAcpClient implements AcpClient {
333
333
  cwd: this.cwd,
334
334
  model: this.model,
335
335
  timeoutSeconds: this.timeoutSeconds,
336
- permissionMode: this.permissionMode,
336
+ permissionMode,
337
337
  env: this.env,
338
338
  pidRegistry: this.pidRegistry,
339
339
  });
@@ -4,6 +4,7 @@
4
4
  * Standalone completion endpoint for simple prompts.
5
5
  */
6
6
 
7
+ import { resolvePermissions } from "../config/permissions";
7
8
  import type { CompleteOptions } from "./types";
8
9
  import { CompleteError } from "./types";
9
10
 
@@ -51,6 +52,11 @@ export async function executeComplete(binary: string, prompt: string, options?:
51
52
  cmd.push("--output-format", "json");
52
53
  }
53
54
 
55
+ const { skipPermissions } = resolvePermissions(options?.config, "complete");
56
+ if (skipPermissions) {
57
+ cmd.push("--dangerously-skip-permissions");
58
+ }
59
+
54
60
  const spawnOpts: { stdout: "pipe"; stderr: "pipe"; cwd?: string } = { stdout: "pipe", stderr: "pipe" };
55
61
  if (options?.workdir) spawnOpts.cwd = options.workdir;
56
62
  const proc = _completeDeps.spawn(cmd, spawnOpts);
@@ -4,6 +4,7 @@
4
4
  * Handles building commands, preparing environment, and process execution.
5
5
  */
6
6
 
7
+ import { resolvePermissions } from "../config/permissions";
7
8
  import type { PidRegistry } from "../execution/pid-registry";
8
9
  import { withProcessTimeout } from "../execution/timeout-handler";
9
10
  import { getLogger } from "../logger";
@@ -49,7 +50,7 @@ export const _runOnceDeps = {
49
50
  */
50
51
  export function buildCommand(binary: string, options: AgentRunOptions): string[] {
51
52
  const model = options.modelDef.model;
52
- const skipPermissions = options.dangerouslySkipPermissions ?? true;
53
+ const { skipPermissions } = resolvePermissions(options.config, options.pipelineStage ?? "run");
53
54
  const permArgs = skipPermissions ? ["--dangerously-skip-permissions"] : [];
54
55
  return [binary, "--model", model, ...permArgs, "-p", options.prompt];
55
56
  }
@@ -7,6 +7,7 @@ import { join } from "node:path";
7
7
  * Extracted from claude.ts: plan(), buildPlanCommand()
8
8
  */
9
9
 
10
+ import { resolvePermissions } from "../config/permissions";
10
11
  import type { PidRegistry } from "../execution/pid-registry";
11
12
  import { withProcessTimeout } from "../execution/timeout-handler";
12
13
  import { getLogger } from "../logger";
@@ -30,8 +31,11 @@ export function buildPlanCommand(binary: string, options: PlanOptions): string[]
30
31
  cmd.push("--model", modelDef.model);
31
32
  }
32
33
 
33
- // Add dangerously-skip-permissions for automation
34
- cmd.push("--dangerously-skip-permissions");
34
+ // Resolve permission mode from config
35
+ const { skipPermissions } = resolvePermissions(options.config as import("../config").NaxConfig | undefined, "plan");
36
+ if (skipPermissions) {
37
+ cmd.push("--dangerously-skip-permissions");
38
+ }
35
39
 
36
40
  // Add prompt with codebase context and input file if available
37
41
  let fullPrompt = options.prompt;
@@ -4,6 +4,7 @@
4
4
  * Main adapter class coordinating execution, completion, decomposition, and interactive modes.
5
5
  */
6
6
 
7
+ import { resolvePermissions } from "../config/permissions";
7
8
  import { PidRegistry } from "../execution/pid-registry";
8
9
  import { withProcessTimeout } from "../execution/timeout-handler";
9
10
  import { getLogger } from "../logger";
@@ -185,7 +186,11 @@ export class ClaudeCodeAdapter implements AgentAdapter {
185
186
  modelDef = resolveBalancedModelDef(options.config);
186
187
  }
187
188
 
188
- const cmd = [this.binary, "--model", modelDef.model, "--dangerously-skip-permissions", "-p", prompt];
189
+ const { skipPermissions } = resolvePermissions(options.config as import("../config").NaxConfig | undefined, "run");
190
+ const cmd = [this.binary, "--model", modelDef.model, "-p", prompt];
191
+ if (skipPermissions) {
192
+ cmd.splice(cmd.length - 2, 0, "--dangerously-skip-permissions");
193
+ }
189
194
 
190
195
  const pidRegistry = this.getPidRegistry(options.workdir);
191
196
 
@@ -6,6 +6,7 @@
6
6
  * collect results from them uniformly.
7
7
  */
8
8
 
9
+ import type { NaxConfig } from "../config";
9
10
  import type { ModelDef, ModelTier } from "../config/schema";
10
11
 
11
12
  // Re-export extended types for backward compatibility
@@ -76,6 +77,10 @@ export interface AgentRunOptions {
76
77
  sessionRole?: string;
77
78
  /** Max turns in multi-turn interaction loop when interactionBridge is active (default: 10) */
78
79
  maxInteractionTurns?: number;
80
+ /** Pipeline stage this run belongs to — used by resolvePermissions() (default: "run") */
81
+ pipelineStage?: import("../config/permissions").PipelineStage;
82
+ /** Full nax config — passed through so adapters can call resolvePermissions() */
83
+ config?: NaxConfig;
79
84
  }
80
85
 
81
86
  /**
@@ -114,6 +119,11 @@ export interface CompleteOptions {
114
119
  * Callers may also wrap complete() in their own Promise.race for shorter timeouts.
115
120
  */
116
121
  timeoutMs?: number;
122
+ /**
123
+ * Full nax config — used by resolvePermissions() to determine permission mode.
124
+ * Pass when available so complete() honours permissionProfile / dangerouslySkipPermissions.
125
+ */
126
+ config?: NaxConfig;
117
127
  }
118
128
 
119
129
  /**
@@ -120,6 +120,7 @@ async function classifyWithLLM(
120
120
  jsonMode: true,
121
121
  maxTokens: 4096,
122
122
  model: modelDef.model,
123
+ config,
123
124
  });
124
125
 
125
126
  // Parse JSON response
@@ -229,7 +229,7 @@ async function runDecomposeDefault(
229
229
  }
230
230
  const adapter = {
231
231
  async decompose(prompt: string): Promise<string> {
232
- return agent.complete(prompt, { jsonMode: true });
232
+ return agent.complete(prompt, { jsonMode: true, config });
233
233
  },
234
234
  };
235
235
  return DecomposeBuilder.for(story).prd(prd).config(builderConfig).decompose(adapter);
package/src/cli/plan.ts CHANGED
@@ -15,6 +15,7 @@ import type { AgentAdapter } from "../agents/types";
15
15
  import { scanCodebase } from "../analyze/scanner";
16
16
  import type { CodebaseScan } from "../analyze/types";
17
17
  import type { NaxConfig } from "../config";
18
+ import { resolvePermissions } from "../config/permissions";
18
19
  import { PidRegistry } from "../execution/pid-registry";
19
20
  import { getLogger } from "../logger";
20
21
  import { validatePlanOutput } from "../prd/schema";
@@ -108,7 +109,7 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
108
109
  const prompt = buildPlanningPrompt(specContent, codebaseContext);
109
110
  const cliAdapter = _deps.getAgent(agentName);
110
111
  if (!cliAdapter) throw new Error(`[plan] No agent adapter found for '${agentName}'`);
111
- rawResponse = await cliAdapter.complete(prompt, { jsonMode: true, workdir });
112
+ rawResponse = await cliAdapter.complete(prompt, { jsonMode: true, workdir, config });
112
113
  // CLI adapter returns {"type":"result","result":"..."} envelope — unwrap it
113
114
  try {
114
115
  const envelope = JSON.parse(rawResponse) as Record<string, unknown>;
@@ -125,13 +126,12 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
125
126
  if (!adapter) throw new Error(`[plan] No agent adapter found for '${agentName}'`);
126
127
  const interactionBridge = createCliInteractionBridge();
127
128
  const pidRegistry = new PidRegistry(workdir);
128
- const dangerouslySkipPermissions = config?.execution?.dangerouslySkipPermissions ?? false;
129
- const permissionMode = dangerouslySkipPermissions ? "approve-all" : "approve-reads";
129
+ const resolvedPerm = resolvePermissions(config, "plan");
130
130
  const resolvedModel = config?.plan?.model ?? "balanced";
131
131
  logger?.info("plan", "Starting interactive planning session", {
132
132
  agent: agentName,
133
133
  model: resolvedModel,
134
- permission: permissionMode,
134
+ permission: resolvedPerm.mode,
135
135
  workdir,
136
136
  feature: options.feature,
137
137
  timeoutSeconds,
@@ -146,7 +146,7 @@ export async function planCommand(workdir: string, config: NaxConfig, options: P
146
146
  interactionBridge,
147
147
  config,
148
148
  modelTier: resolvedModel,
149
- dangerouslySkipPermissions,
149
+ dangerouslySkipPermissions: resolvedPerm.skipPermissions,
150
150
  maxInteractionTurns: config?.agent?.maxInteractionTurns,
151
151
  featureName: options.feature,
152
152
  pidRegistry,
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Permission Resolver — Single Source of Truth
3
+ *
4
+ * All adapters call resolvePermissions() to determine permission mode.
5
+ * No local fallbacks allowed elsewhere in the codebase.
6
+ *
7
+ * Phase 1: permissionProfile field + legacy boolean backward compat.
8
+ * Phase 2: per-stage scoped allowlists (stub below).
9
+ */
10
+
11
+ import type { NaxConfig } from "./schema";
12
+
13
+ export type PermissionProfile = "unrestricted" | "safe" | "scoped";
14
+
15
+ export type PipelineStage =
16
+ | "plan"
17
+ | "run"
18
+ | "verify"
19
+ | "review"
20
+ | "rectification"
21
+ | "regression"
22
+ | "acceptance"
23
+ | "complete";
24
+
25
+ export interface ResolvedPermissions {
26
+ /** ACP permission mode string */
27
+ mode: "approve-all" | "approve-reads" | "default";
28
+ /** CLI adapter: whether to pass --dangerously-skip-permissions */
29
+ skipPermissions: boolean;
30
+ /** Future: scoped tool allowlist (Phase 2) */
31
+ allowedTools?: string[];
32
+ }
33
+
34
+ /**
35
+ * Resolve permissions for a given pipeline stage.
36
+ * Single source of truth — all adapters call this.
37
+ *
38
+ * Precedence: permissionProfile > dangerouslySkipPermissions boolean > safe default.
39
+ */
40
+ export function resolvePermissions(config: NaxConfig | undefined, _stage: PipelineStage): ResolvedPermissions {
41
+ const profile: PermissionProfile =
42
+ config?.execution?.permissionProfile ?? (config?.execution?.dangerouslySkipPermissions ? "unrestricted" : "safe");
43
+
44
+ switch (profile) {
45
+ case "unrestricted":
46
+ return { mode: "approve-all", skipPermissions: true };
47
+ case "safe":
48
+ return { mode: "approve-reads", skipPermissions: false };
49
+ case "scoped":
50
+ return resolveScopedPermissions(config, _stage);
51
+ default:
52
+ return { mode: "approve-reads", skipPermissions: false };
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Phase 2 stub — resolves per-stage permissions from config block.
58
+ * Returns safe defaults until Phase 2 is implemented.
59
+ */
60
+ function resolveScopedPermissions(_config: NaxConfig | undefined, _stage: PipelineStage): ResolvedPermissions {
61
+ // Phase 2 implementation goes here
62
+ return { mode: "approve-reads", skipPermissions: false };
63
+ }
@@ -97,6 +97,17 @@ export interface ExecutionConfig {
97
97
  typecheckCommand?: string | null;
98
98
  /** Use --dangerously-skip-permissions flag for agent (default: true for backward compat, SEC-1 fix) */
99
99
  dangerouslySkipPermissions?: boolean;
100
+ /** Permission profile — takes precedence over dangerouslySkipPermissions (Phase 1) */
101
+ permissionProfile?: "unrestricted" | "safe" | "scoped";
102
+ /** Per-stage permission overrides — only read when permissionProfile = "scoped" (Phase 2) */
103
+ permissions?: Record<
104
+ string,
105
+ {
106
+ mode: "approve-all" | "approve-reads" | "scoped";
107
+ allowedTools?: string[];
108
+ inherit?: string;
109
+ }
110
+ >;
100
111
  /** Enable smart test runner to scope test runs to changed files (default: true).
101
112
  * Accepts boolean for backward compat or a SmartTestRunnerConfig object. */
102
113
  smartTestRunner?: boolean | SmartTestRunnerConfig;
@@ -105,7 +105,21 @@ const ExecutionConfigSchema = z.object({
105
105
  .default(2000),
106
106
  lintCommand: z.string().nullable().optional(),
107
107
  typecheckCommand: z.string().nullable().optional(),
108
+ // DEPRECATED — use permissionProfile instead. Kept for backward compat.
108
109
  dangerouslySkipPermissions: z.boolean().default(true),
110
+ // NEW — takes precedence over dangerouslySkipPermissions
111
+ permissionProfile: z.enum(["unrestricted", "safe", "scoped"]).optional(),
112
+ // Phase 2: per-stage permission overrides (only read when profile = "scoped")
113
+ permissions: z
114
+ .record(
115
+ z.string(),
116
+ z.object({
117
+ mode: z.enum(["approve-all", "approve-reads", "scoped"]),
118
+ allowedTools: z.array(z.string()).optional(),
119
+ inherit: z.string().optional(),
120
+ }),
121
+ )
122
+ .optional(),
109
123
  smartTestRunner: smartTestRunnerFieldSchema,
110
124
  });
111
125
 
@@ -156,6 +156,7 @@ export class AutoInteractionPlugin implements InteractionPlugin {
156
156
  const output = await adapter.complete(prompt, {
157
157
  ...(modelArg && { model: modelArg }),
158
158
  jsonMode: true,
159
+ ...(this.config.naxConfig && { config: this.config.naxConfig }),
159
160
  });
160
161
 
161
162
  return this.parseResponse(output);
@@ -32,6 +32,7 @@
32
32
 
33
33
  import { getAgent, validateAgentForTier } from "../../agents";
34
34
  import { resolveModel } from "../../config";
35
+ import { resolvePermissions } from "../../config/permissions";
35
36
  import { checkMergeConflict, checkStoryAmbiguity, isTriggerEnabled } from "../../interaction/triggers";
36
37
  import { getLogger } from "../../logger";
37
38
  import type { FailureCategory } from "../../tdd";
@@ -217,7 +218,9 @@ export const executionStage: PipelineStage = {
217
218
  modelTier: ctx.routing.modelTier,
218
219
  modelDef: resolveModel(ctx.config.models[ctx.routing.modelTier]),
219
220
  timeoutSeconds: ctx.config.execution.sessionTimeoutSeconds,
220
- dangerouslySkipPermissions: ctx.config.execution.dangerouslySkipPermissions,
221
+ dangerouslySkipPermissions: resolvePermissions(ctx.config, "run").skipPermissions,
222
+ pipelineStage: "run",
223
+ config: ctx.config,
221
224
  maxInteractionTurns: ctx.config.agent?.maxInteractionTurns,
222
225
  pidRegistry: ctx.pidRegistry,
223
226
  featureName: ctx.prd.feature,
@@ -66,7 +66,7 @@ async function runDecompose(
66
66
  }
67
67
  const adapter = {
68
68
  async decompose(prompt: string): Promise<string> {
69
- return agent.complete(prompt, { jsonMode: true });
69
+ return agent.complete(prompt, { jsonMode: true, config });
70
70
  },
71
71
  };
72
72
 
@@ -111,7 +111,7 @@ async function callLlmOnce(
111
111
  // Prevent unhandled rejection if timer fires between race resolution and clearTimeout
112
112
  timeoutPromise.catch(() => {});
113
113
 
114
- const outputPromise = adapter.complete(prompt, { model: modelArg });
114
+ const outputPromise = adapter.complete(prompt, { model: modelArg, config });
115
115
 
116
116
  try {
117
117
  const result = await Promise.race([outputPromise, timeoutPromise]);
@@ -9,6 +9,7 @@
9
9
  import type { AgentAdapter } from "../agents";
10
10
  import type { ModelTier, NaxConfig } from "../config";
11
11
  import { resolveModel } from "../config";
12
+ import { resolvePermissions } from "../config/permissions";
12
13
  import type { getLogger } from "../logger";
13
14
  import type { UserStory } from "../prd";
14
15
  import { autoCommitIfDirty, captureGitRef } from "../utils/git";
@@ -158,7 +159,9 @@ async function runRectificationLoop(
158
159
  modelTier: implementerTier,
159
160
  modelDef: resolveModel(config.models[implementerTier]),
160
161
  timeoutSeconds: config.execution.sessionTimeoutSeconds,
161
- dangerouslySkipPermissions: config.execution.dangerouslySkipPermissions,
162
+ dangerouslySkipPermissions: resolvePermissions(config, "rectification").skipPermissions,
163
+ pipelineStage: "rectification",
164
+ config,
162
165
  maxInteractionTurns: config.agent?.maxInteractionTurns,
163
166
  featureName,
164
167
  storyId: story.id,
@@ -7,6 +7,7 @@
7
7
  import type { AgentAdapter } from "../agents";
8
8
  import type { ModelTier, NaxConfig } from "../config";
9
9
  import { resolveModel } from "../config";
10
+ import { resolvePermissions } from "../config/permissions";
10
11
  import { getLogger } from "../logger";
11
12
  import type { UserStory } from "../prd";
12
13
  import { PromptBuilder } from "../prompts";
@@ -139,7 +140,9 @@ export async function runTddSession(
139
140
  modelTier,
140
141
  modelDef: resolveModel(config.models[modelTier]),
141
142
  timeoutSeconds: config.execution.sessionTimeoutSeconds,
142
- dangerouslySkipPermissions: config.execution.dangerouslySkipPermissions,
143
+ dangerouslySkipPermissions: resolvePermissions(config, "run").skipPermissions,
144
+ pipelineStage: "run",
145
+ config,
143
146
  maxInteractionTurns: config.agent?.maxInteractionTurns,
144
147
  featureName,
145
148
  storyId: story.id,
@@ -10,6 +10,7 @@
10
10
  import { getAgent } from "../agents";
11
11
  import type { NaxConfig } from "../config";
12
12
  import { resolveModel } from "../config";
13
+ import { resolvePermissions } from "../config/permissions";
13
14
  import { parseBunTestOutput } from "../execution/test-output-parser";
14
15
  import { getSafeLogger } from "../logger";
15
16
  import type { UserStory } from "../prd";
@@ -73,7 +74,9 @@ export async function runRectificationLoop(opts: RectificationLoopOptions): Prom
73
74
  modelTier,
74
75
  modelDef,
75
76
  timeoutSeconds: config.execution.sessionTimeoutSeconds,
76
- dangerouslySkipPermissions: config.execution.dangerouslySkipPermissions,
77
+ dangerouslySkipPermissions: resolvePermissions(config, "rectification").skipPermissions,
78
+ pipelineStage: "rectification",
79
+ config,
77
80
  maxInteractionTurns: config.agent?.maxInteractionTurns,
78
81
  });
79
82