@ordewell/core 0.8.1 → 0.9.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/dist/index.mjs CHANGED
@@ -97,7 +97,7 @@ import {
97
97
  unsendLatest,
98
98
  usageLine,
99
99
  validateTaskEdit
100
- } from "./chunk-BLLIBCCS.mjs";
100
+ } from "./chunk-QUARDKVT.mjs";
101
101
  import {
102
102
  PLAN_ENVELOPE_KEY,
103
103
  PlanParseError,
@@ -450,7 +450,7 @@ function ansiCQuoteEnd(s, start) {
450
450
  function backtickBody(body, dialect) {
451
451
  return dialect.escape === "\\" ? body.replace(/\\([$`\\])/g, "$1") : body;
452
452
  }
453
- function skipHereDocuments(s, start, docs, nested, dialect) {
453
+ function skipHereDocuments(s, start, docs, nested2, dialect) {
454
454
  let pos = start;
455
455
  for (const doc of docs) {
456
456
  const bodyStart = pos;
@@ -477,19 +477,19 @@ function skipHereDocuments(s, start, docs, nested, dialect) {
477
477
  if (body[i] === "$" && body[i + 1] === "(") {
478
478
  const close = matchParen(body, i + 1, dialect);
479
479
  if (close < 0) return close;
480
- nested.push(body.slice(i + 2, close));
480
+ nested2.push(body.slice(i + 2, close));
481
481
  i = close;
482
482
  } else if (body[i] === "`") {
483
483
  const close = matchBacktick(body, i, dialect);
484
484
  if (close < 0) return -1;
485
- nested.push(backtickBody(body.slice(i + 1, close), dialect));
485
+ nested2.push(backtickBody(body.slice(i + 1, close), dialect));
486
486
  i = close;
487
487
  }
488
488
  }
489
489
  }
490
490
  return pos;
491
491
  }
492
- function lex(command, nested, dialect) {
492
+ function lex(command, nested2, dialect) {
493
493
  const segments = [];
494
494
  let unsafeRedirect;
495
495
  let processSubstitution = false;
@@ -503,6 +503,7 @@ function lex(command, nested, dialect) {
503
503
  let bareSegment = false;
504
504
  let tokens = [];
505
505
  let expandable = false;
506
+ let globbed = false;
506
507
  let current = "";
507
508
  let started = false;
508
509
  let piped = false;
@@ -566,7 +567,7 @@ function lex(command, nested, dialect) {
566
567
  followable = false;
567
568
  bareSegment = true;
568
569
  }
569
- segments.push({ ...seg, expandable, stdinRedirected, inputs, joinedBy });
570
+ segments.push({ ...seg, expandable, globbed, stdinRedirected, inputs, joinedBy });
570
571
  } else if (!last || segments.length > 0 || joinedBy !== void 0) {
571
572
  followable = false;
572
573
  bareSegment = true;
@@ -574,6 +575,7 @@ function lex(command, nested, dialect) {
574
575
  tokens = [];
575
576
  inputs = [];
576
577
  expandable = false;
578
+ globbed = false;
577
579
  stdinRedirected = false;
578
580
  piped = nextPiped;
579
581
  joinedBy = next;
@@ -612,7 +614,7 @@ function lex(command, nested, dialect) {
612
614
  unbalanced = true;
613
615
  break;
614
616
  }
615
- nested.push(command.slice(i + 2, close));
617
+ nested2.push(command.slice(i + 2, close));
616
618
  current += command.slice(i, close + 1);
617
619
  started = true;
618
620
  expandable = true;
@@ -625,7 +627,7 @@ function lex(command, nested, dialect) {
625
627
  unbalanced = true;
626
628
  break;
627
629
  }
628
- nested.push(backtickBody(command.slice(i + 1, close), dialect));
630
+ nested2.push(backtickBody(command.slice(i + 1, close), dialect));
629
631
  current += command.slice(i, close + 1);
630
632
  started = true;
631
633
  expandable = true;
@@ -756,7 +758,7 @@ function lex(command, nested, dialect) {
756
758
  endSegment(false, c === "&" && command[i + 1] === "&" ? "and" : "other");
757
759
  if (c === "\n") inComment = false;
758
760
  if (c === "\n" && hereDocs.length > 0) {
759
- const end = skipHereDocuments(command, i + 1, hereDocs.splice(0), nested, dialect);
761
+ const end = skipHereDocuments(command, i + 1, hereDocs.splice(0), nested2, dialect);
760
762
  if (end === UNREADABLE) {
761
763
  unreadable = true;
762
764
  break;
@@ -784,6 +786,7 @@ function lex(command, nested, dialect) {
784
786
  else if (c === "{") braceOpen = true;
785
787
  else if (braceOpen && (c === "," || c === "." && command[i + 1] === ".")) braceList = true;
786
788
  else if (braceOpen && braceList && c === "}") expandable = true;
789
+ else if (c === "*" || c === "?" || c === "[") globbed = true;
787
790
  current += c;
788
791
  started = true;
789
792
  i++;
@@ -819,8 +822,10 @@ function toSegment(tokens, piped, dialect) {
819
822
  piped,
820
823
  stdinRedirected: false,
821
824
  expandable: false,
825
+ globbed: false,
822
826
  inputs: [],
823
827
  ...first !== void 0 && isComputedWord(first, dialect) ? { computedBinary: first } : {},
828
+ ...first !== void 0 && /[/\\]/.test(first) ? { binaryPath: first } : {},
824
829
  ...ambiguousCmdName !== void 0 ? { ambiguousCmdName } : {}
825
830
  };
826
831
  }
@@ -1109,7 +1114,7 @@ function unwrapXargs(seg, dialect) {
1109
1114
  if (replace && tokens[0].includes(replace)) {
1110
1115
  return { reason: `"xargs" substitutes its input into "${tokens[0]}", so the input decides which program runs. Name the program directly.` };
1111
1116
  }
1112
- return { seg: { ...toSegment(tokens, false, dialect), expandable: seg.expandable } };
1117
+ return { seg: { ...toSegment(tokens, false, dialect), expandable: seg.expandable, globbed: seg.globbed } };
1113
1118
  }
1114
1119
  function unwrap(seg, dialect) {
1115
1120
  const wrappers = [];
@@ -1140,7 +1145,7 @@ function unwrap(seg, dialect) {
1140
1145
  wrappers.push(current.binary);
1141
1146
  const inner = toSegment(scan.tokens, current.piped, dialect);
1142
1147
  assignments.push(...inner.assignments);
1143
- current = { ...inner, assignments: [], expandable: current.expandable, stdinRedirected: current.stdinRedirected };
1148
+ current = { ...inner, assignments: [], expandable: current.expandable, globbed: current.globbed, stdinRedirected: current.stdinRedirected };
1144
1149
  }
1145
1150
  return { seg: { ...current, assignments }, wrappers, runner };
1146
1151
  }
@@ -2991,6 +2996,340 @@ function awkProgramHazard(program) {
2991
2996
  return void 0;
2992
2997
  }
2993
2998
 
2999
+ // src/services/plannerAllowlist.ts
3000
+ var MUTATING_VERB = /^(abort|ack|activate|add|apply|approve|archive|assign|attach|bind|call|cancel|clear|clone|close|commit|copy|cp|create|deactivate|delete|deploy|destroy|detach|disable|download|drain|drop|dump|edit|enable|evict|exec|execute|expire|export|failover|flush|grant|import|insert|install|invoke|kill|label|lock|login|logout|merge|migrate|modify|move|mv|patch|pause|promote|publish|pull|purge|push|put|reboot|redeploy|register|reject|release|remove|rename|reopen|replace|request|reset|resize|restart|restore|resume|revoke|rm|rollback|rotate|run|scale|scp|send|set|ssh|start|stop|submit|suspend|sync|rsync|tag|taint|terminate|transfer|truncate|undelete|uninstall|unlock|unregister|unset|untag|update|upgrade|upload|write)([-_:.].*)?$/i;
3001
+ function globRegExp(glob, flags = "") {
3002
+ const escaped = glob.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*");
3003
+ return new RegExp(`^${escaped}$`, flags);
3004
+ }
3005
+ function ruleWord(pattern) {
3006
+ if (!pattern.includes("*")) return { matches: (word) => word === pattern, wildcard: false };
3007
+ const re = globRegExp(pattern);
3008
+ return { matches: (word) => re.test(word), wildcard: true };
3009
+ }
3010
+ function exclusionWord(pattern) {
3011
+ const re = /^-[A-Za-z]$/.test(pattern) ? new RegExp(`^-[A-Za-z]*${pattern[1]}`) : globRegExp(pattern, "i");
3012
+ return { matches: (word) => re.test(word), wildcard: pattern.includes("*") };
3013
+ }
3014
+ function isScopePattern(entry) {
3015
+ return /^(\/|~|https?:\/\/|mcp__|[A-Za-z]:[\\/])/.test(entry);
3016
+ }
3017
+ function parseAllowlist(entries) {
3018
+ const list = { commands: [], commandExclusions: [], tools: [], toolExclusions: [], scopes: [], scopeExclusions: [] };
3019
+ for (const raw of entries) {
3020
+ const exclude = raw.trim().startsWith("!");
3021
+ const entry = raw.trim().replace(/^!\s*/, "");
3022
+ if (!entry) continue;
3023
+ if (entry.startsWith("mcp:")) {
3024
+ (exclude ? list.toolExclusions : list.tools).push(globRegExp(entry.slice(4).trim(), "i"));
3025
+ } else if (isScopePattern(entry)) {
3026
+ (exclude ? list.scopeExclusions : list.scopes).push(globRegExp(entry));
3027
+ } else {
3028
+ const [binary, ...words] = entry.split(/\s+/);
3029
+ (exclude ? list.commandExclusions : list.commands).push({ text: raw.trim(), binary, words: words.map(exclude ? exclusionWord : ruleWord) });
3030
+ }
3031
+ }
3032
+ return list;
3033
+ }
3034
+ function leadingWords(args) {
3035
+ const lead = [];
3036
+ for (const arg of args) {
3037
+ if (arg.startsWith("-")) break;
3038
+ lead.push(arg);
3039
+ }
3040
+ return lead;
3041
+ }
3042
+ function ruleCovers(rule, command) {
3043
+ if (rule.binary !== command.binary) return false;
3044
+ const lead = leadingWords(command.args);
3045
+ if (lead.length < rule.words.length || rule.words.length < (command.scopeWords ?? 0)) return false;
3046
+ return rule.words.every((word, i) => word.matches(lead[i]) && !(word.wildcard && MUTATING_VERB.test(lead[i])));
3047
+ }
3048
+ function exclusionCovers(rule, command) {
3049
+ if (rule.binary !== "*" && rule.binary !== command.binary) return false;
3050
+ let next = 0;
3051
+ for (const arg of command.args) {
3052
+ if (next < rule.words.length && rule.words[next].matches(arg)) next += 1;
3053
+ }
3054
+ return next === rule.words.length;
3055
+ }
3056
+ function commandAllowedBy(list, command) {
3057
+ if (list.commandExclusions.some((rule) => exclusionCovers(rule, command))) return void 0;
3058
+ return list.commands.find((rule) => ruleCovers(rule, command))?.text;
3059
+ }
3060
+ function toolAllowed(list, tool2) {
3061
+ if (list.toolExclusions.some((re) => re.test(tool2))) return false;
3062
+ return list.tools.some((re) => re.test(tool2));
3063
+ }
3064
+ function scopeAllowed(list, scope) {
3065
+ if (list.scopeExclusions.some((re) => re.test(scope))) return false;
3066
+ return list.scopes.some((re) => re.test(scope)) || list.commands.some((rule) => rule.text === scope);
3067
+ }
3068
+ function withArgument(binary, verbs) {
3069
+ return verbs.flatMap((verb) => [`${binary} ${verb}`, `${binary} ${verb} *`]);
3070
+ }
3071
+ function nested(binary, verbs, depth) {
3072
+ return verbs.flatMap((verb) => Array.from({ length: depth }, (_, i) => [binary, ...Array(i).fill("*"), verb].join(" ")));
3073
+ }
3074
+ var KUBE_VERB_READS = ["get", "describe", "logs", "top", "explain", "events"];
3075
+ var KUBE_READS = [
3076
+ "api-resources",
3077
+ "api-versions",
3078
+ "version",
3079
+ "cluster-info",
3080
+ "auth can-i",
3081
+ "auth whoami",
3082
+ "config get-contexts",
3083
+ "config current-context",
3084
+ "config get-clusters",
3085
+ "rollout status",
3086
+ "rollout history"
3087
+ ];
3088
+ var CONTAINER_VERB_READS = ["ps", "images", "inspect", "logs", "history", "top", "port"];
3089
+ var CONTAINER_READS = [
3090
+ "version",
3091
+ "info",
3092
+ "image ls",
3093
+ "image list",
3094
+ "image inspect",
3095
+ "image history",
3096
+ "container ls",
3097
+ "container list",
3098
+ "container inspect",
3099
+ "container logs",
3100
+ "network ls",
3101
+ "network inspect",
3102
+ "volume ls",
3103
+ "volume inspect",
3104
+ "context ls",
3105
+ "context list"
3106
+ ];
3107
+ var DEFAULT_PLANNER_ALLOWLIST = [
3108
+ // GitHub, GitLab, Jira
3109
+ ...[
3110
+ "issue list",
3111
+ "issue view",
3112
+ "issue status",
3113
+ "pr list",
3114
+ "pr view",
3115
+ "pr diff",
3116
+ "pr checks",
3117
+ "pr status",
3118
+ "run list",
3119
+ "run view",
3120
+ "workflow list",
3121
+ "workflow view",
3122
+ "repo view",
3123
+ "repo list",
3124
+ "search",
3125
+ "label list",
3126
+ "search *",
3127
+ "status",
3128
+ "cache list",
3129
+ "project list",
3130
+ "project view",
3131
+ "project item-list",
3132
+ "gist list",
3133
+ "gist view",
3134
+ "variable list",
3135
+ "ruleset list",
3136
+ "ruleset view",
3137
+ "org list"
3138
+ ].map((c) => `gh ${c}`),
3139
+ ...[
3140
+ "issue list",
3141
+ "issue view",
3142
+ "mr list",
3143
+ "mr view",
3144
+ "mr diff",
3145
+ "ci list",
3146
+ "ci status",
3147
+ "repo view",
3148
+ "release list",
3149
+ "release view"
3150
+ ].map((c) => `glab ${c}`),
3151
+ ...["issue list", "issue view", "sprint list", "epic list", "project list"].map((c) => `jira ${c}`),
3152
+ "!gh -w",
3153
+ "!glab -w",
3154
+ // Azure: `az` takes no positional arguments, so a group path is always groups then verb.
3155
+ ...nested("az", ["list", "show"], 5),
3156
+ "az version",
3157
+ "az lock list",
3158
+ "az tag list",
3159
+ "az monitor log-analytics query",
3160
+ "az monitor app-insights query",
3161
+ "!az *keys*",
3162
+ "!az *secret*",
3163
+ "!az *credential*",
3164
+ "!az *password*",
3165
+ "!az *connection-string*",
3166
+ "!az *appsettings*",
3167
+ "!az *token*",
3168
+ // Google Cloud. `run` is spelled out: a wildcard never stands for it.
3169
+ ...nested("gcloud", ["list", "describe", "get-iam-policy"], 5),
3170
+ "gcloud run * list",
3171
+ "gcloud run * * list",
3172
+ "gcloud run * describe",
3173
+ "gcloud run * * describe",
3174
+ "gcloud config list",
3175
+ "gcloud config get",
3176
+ "gcloud config get-value",
3177
+ "gcloud auth list",
3178
+ "gcloud logging read",
3179
+ "gcloud info",
3180
+ "gcloud version",
3181
+ ...["ls", "du"].map((c) => `gsutil ${c}`),
3182
+ ...["ls", "show", "head"].map((c) => `bq ${c}`),
3183
+ "!gcloud *secret*",
3184
+ "!gcloud *token*",
3185
+ // AWS: always `aws <service> <operation>`.
3186
+ "aws * describe-*",
3187
+ "aws * list-*",
3188
+ "aws s3 ls",
3189
+ "aws sts get-caller-identity",
3190
+ "aws configure list",
3191
+ "aws logs filter-log-events",
3192
+ "aws logs get-log-events",
3193
+ "aws cloudwatch get-metric-data",
3194
+ "aws cloudwatch get-metric-statistics",
3195
+ "aws ce get-cost-and-usage",
3196
+ "aws iam get-user",
3197
+ "aws iam get-role",
3198
+ "!aws secretsmanager",
3199
+ "!aws *secret*",
3200
+ // Other clouds and platforms
3201
+ ...nested("doctl", ["list", "get"], 3),
3202
+ "doctl account get",
3203
+ "!doctl -u",
3204
+ ...nested("hcloud", ["list", "describe"], 2),
3205
+ ...nested("linode-cli", ["list", "view"], 2),
3206
+ ...nested("oci", ["list"], 4),
3207
+ ...[
3208
+ "status",
3209
+ "releases",
3210
+ "apps list",
3211
+ "machine list",
3212
+ "machines list",
3213
+ "secrets list",
3214
+ "regions list",
3215
+ "ips list",
3216
+ "volumes list"
3217
+ ].flatMap((c) => [`fly ${c}`, `flyctl ${c}`]),
3218
+ ...["apps", "apps:info", "ps", "logs", "releases", "addons", "domains", "pipelines"].map((c) => `heroku ${c}`),
3219
+ ...["ls", "list", "inspect", "logs", "env ls", "domains ls", "project ls", "whoami"].map((c) => `vercel ${c}`),
3220
+ "netlify status",
3221
+ "netlify sites:list",
3222
+ "firebase projects:list",
3223
+ "firebase apps:list",
3224
+ "firebase hosting:channel:list",
3225
+ "wrangler whoami",
3226
+ "wrangler deployments list",
3227
+ "wrangler d1 list",
3228
+ "wrangler kv namespace list",
3229
+ "supabase projects list",
3230
+ "supabase functions list",
3231
+ "railway status",
3232
+ // Kubernetes and friends. A kubeconfig the command names can run a credential plugin; `--raw` reads any API path.
3233
+ ...withArgument("kubectl", KUBE_VERB_READS),
3234
+ ...KUBE_READS.map((c) => `kubectl ${c}`),
3235
+ ...[...KUBE_VERB_READS, ...KUBE_READS, "status", "whoami", "projects"].map((c) => `oc ${c}`),
3236
+ ...["kubectl", "oc"].flatMap((bin) => ["*secret*", "-s", "dump", "--raw*", "-k", "--kustomize*"].map((w) => `!${bin} ${w}`)),
3237
+ "!oc whoami -t",
3238
+ "!oc *show-token*",
3239
+ ...withArgument("helm", ["list", "ls", "status", "history", "search"]),
3240
+ "helm version",
3241
+ "!helm --kube*",
3242
+ "eksctl get",
3243
+ "kind get",
3244
+ "k3d cluster list",
3245
+ "minikube status",
3246
+ "minikube profile list",
3247
+ "!kind *kubeconfig*",
3248
+ "argocd app list",
3249
+ "argocd app get",
3250
+ "argocd app history",
3251
+ "istioctl analyze",
3252
+ "istioctl proxy-status",
3253
+ "istioctl version",
3254
+ "!istioctl -c",
3255
+ "nomad status",
3256
+ "nomad job status",
3257
+ "consul members",
3258
+ "consul catalog services",
3259
+ "vault status",
3260
+ // Containers. Not `compose`, which reads the repository's compose file, remote includes and all.
3261
+ ...["docker", "podman"].flatMap((bin) => [...withArgument(bin, CONTAINER_VERB_READS), ...CONTAINER_READS.map((c) => `${bin} ${c}`), `!${bin} -H`]),
3262
+ // Infrastructure as code: only what reads no backend, whose address the repository's config sets.
3263
+ ...["version", "workspace show"].flatMap((c) => [`terraform ${c}`, `tofu ${c}`]),
3264
+ // Package metadata from what is installed: npm reads `node_modules`, pip its own environment.
3265
+ ...withArgument("npm", ["ls", "list", "why", "explain"]),
3266
+ ...["pip", "pip3"].flatMap((bin) => [...withArgument(bin, ["show"]), `${bin} list`, `${bin} freeze`, `!${bin} -i`, `!${bin} -f`]),
3267
+ // A module query (`@latest`) fetches from whatever host the module path names.
3268
+ ...withArgument("go", ["list", "env"]),
3269
+ "go version",
3270
+ "go mod graph",
3271
+ "go mod why",
3272
+ "!go -w",
3273
+ "!go -u",
3274
+ "!go -mod*",
3275
+ "!go *-toolexec*",
3276
+ "!go *-exec*",
3277
+ "!go *-compiled*",
3278
+ "!go *-export*",
3279
+ "!go *@*",
3280
+ // The local machine
3281
+ "ps",
3282
+ "lsof",
3283
+ "uptime",
3284
+ "free",
3285
+ "nproc",
3286
+ "hostname",
3287
+ "id",
3288
+ "journalctl",
3289
+ "!journalctl --vacuum*",
3290
+ "!journalctl --rotate",
3291
+ "!journalctl --flush",
3292
+ "!journalctl --sync",
3293
+ "!journalctl --relinquish*",
3294
+ "!journalctl --smart-relinquish*",
3295
+ "!journalctl --setup-keys",
3296
+ "!journalctl --update-catalog",
3297
+ "!hostname *",
3298
+ // Anything that would write a file, open a browser, or send the user's credentials to an address the command names.
3299
+ ...[
3300
+ "--output-file*",
3301
+ "--out-file*",
3302
+ "--outfile*",
3303
+ "--output-document*",
3304
+ "--output-directory*",
3305
+ "--log-file*",
3306
+ "--logfile*",
3307
+ "--download*",
3308
+ "--show-secrets*",
3309
+ "--with-decryption*",
3310
+ "--reveal*",
3311
+ "--web",
3312
+ "--server*",
3313
+ "*-addr*",
3314
+ "--endpoint*",
3315
+ "--api-url*",
3316
+ "--url*",
3317
+ "--host*",
3318
+ "--registry*",
3319
+ "--index-url*",
3320
+ "--extra-index-url*",
3321
+ "--find-links*",
3322
+ "--proxy*",
3323
+ "--kubeconfig*",
3324
+ "*apiserver*",
3325
+ "*http://*",
3326
+ "*https://*"
3327
+ ].map((w) => `!* ${w}`),
3328
+ // MCP tools that read: the verb leads the tool's own name.
3329
+ ...["get*", "list*", "find*", "search*", "read*", "view*", "describe*", "show*"].map((v) => `mcp:${v}`),
3330
+ ...["*secret*", "*token*", "*password*", "*credential*", "*key*", "*url*", "*fetch*", "*http*"].map((w) => `!mcp:${w}`)
3331
+ ];
3332
+
2994
3333
  // src/services/commandPolicy.ts
2995
3334
  var AUTO_COMMANDS = [
2996
3335
  "ls",
@@ -3424,18 +3763,20 @@ function patternArgs(seg) {
3424
3763
  }
3425
3764
  var SCOPE_LEAD_ARGS = 2;
3426
3765
  function scopeFor(seg) {
3427
- if (!MULTIPLEXERS.includes(seg.binary.toLowerCase())) return seg.binary;
3766
+ const name = seg.binaryPath ?? seg.binary;
3767
+ if (!MULTIPLEXERS.includes(seg.binary.toLowerCase())) return name;
3428
3768
  const lead = [];
3429
3769
  for (const arg of seg.args) {
3430
3770
  if (arg.startsWith("-")) break;
3431
3771
  lead.push(arg);
3432
3772
  if (lead.length === SCOPE_LEAD_ARGS) break;
3433
3773
  }
3434
- return [seg.binary, ...lead].join(" ");
3774
+ return [name, ...lead].join(" ");
3435
3775
  }
3436
3776
  function isAuto(seg, opts) {
3437
3777
  if (seg.binary === "cd") return !opts.cdpathSet && !seg.cdSteered && literalCdTarget(seg) !== void 0;
3438
3778
  if (!AUTO_COMMANDS.includes(seg.binary)) return false;
3779
+ if (seg.binaryPath !== void 0) return false;
3439
3780
  if (seg.expandable) return false;
3440
3781
  if (seg.binary !== "git") return true;
3441
3782
  const sub = subcommandOf(seg);
@@ -3622,7 +3963,18 @@ function classifyCommand(command, opts = {}) {
3622
3963
  return { tier: "ask", scope: trimmed };
3623
3964
  }
3624
3965
  const scope = [...new Set(nonAuto.map((u) => u.runner ? `${u.runner} ${scopeFor(u.seg)}` : scopeFor(u.seg)))].sort().join(" + ");
3625
- return { tier: "ask", scope };
3966
+ const allowedBy = opts.allow ? allowlistRule(nonAuto, opts.allow) : void 0;
3967
+ return { tier: "ask", scope, ...allowedBy ? { allowedBy } : {} };
3968
+ }
3969
+ function allowlistRule(parts, allow) {
3970
+ const rules = [];
3971
+ for (const { seg, runner } of parts) {
3972
+ if (runner || seg.expandable || seg.globbed || seg.binaryPath !== void 0 || seg.assignments.length > 0) return void 0;
3973
+ const rule = commandAllowedBy(allow, { binary: seg.binary, args: seg.args, scopeWords: scopeFor(seg).split(" ").length - 1 });
3974
+ if (!rule) return void 0;
3975
+ rules.push(rule);
3976
+ }
3977
+ return [...new Set(rules)].join(" + ");
3626
3978
  }
3627
3979
 
3628
3980
  // src/services/researchShell.ts
@@ -3938,7 +4290,8 @@ ${refs.output.trim()}`);
3938
4290
  * directory the next command names.
3939
4291
  */
3940
4292
  async bash(command, signal) {
3941
- const { tier, scope, reason } = classifyCommand(command, this.policyOptions());
4293
+ const allow = this.approval.allowlist?.();
4294
+ const { tier, scope, reason, allowedBy } = classifyCommand(command, { ...this.policyOptions(), ...allow ? { allow } : {} });
3942
4295
  if (tier === "refuse") {
3943
4296
  return { success: false, output: `Command refused: ${reason}`, truncated: false };
3944
4297
  }
@@ -3953,13 +4306,14 @@ ${refs.output.trim()}`);
3953
4306
  subject: asks ? command : outside[0].abs,
3954
4307
  scope: scopes[0],
3955
4308
  ...scopes.length > 1 ? { scopes } : {},
4309
+ ...asks && allowedBy ? { allowedBy } : {},
3956
4310
  detail: outside.length > 0 ? `Planner research wants to run "${command}", which touches ${touched}, outside the workspace (${root}).` : `Planner research wants to run: ${command}`
3957
4311
  });
3958
4312
  if (!granted) {
3959
4313
  return {
3960
4314
  success: false,
3961
4315
  output: outside.length > 0 ? `Access denied: "${command}" touches ${touched}, outside the workspace root (${root}), and was not approved. Keep research inside the workspace, or ask the user to approve this location.` : `Command not approved: ${command}
3962
- It is outside the auto-allowed read-only set (${scope}). Continue with the read-only research tools, or ask the user to approve it.`,
4316
+ It is outside the auto-allowed read-only set and the planner allowlist (${scope}). Continue without it. If it would change something outside the repository, add it to the plan as an ops task; if it only reads, ask the user to approve it or add it to the planner allowlist.`,
3963
4317
  truncated: false
3964
4318
  };
3965
4319
  }
@@ -4187,14 +4541,19 @@ async function runTool(name, args, fs17, fetcher, signal) {
4187
4541
  }
4188
4542
 
4189
4543
  // src/services/ApprovalPolicy.ts
4190
- function scopeMatches(pattern, scope) {
4191
- if (pattern === scope) return true;
4192
- if (pattern.endsWith("*")) return scope.startsWith(pattern.slice(0, -1));
4193
- return false;
4544
+ function effectiveApprovalMode(setting, autonomous) {
4545
+ if (setting !== "auto") return setting;
4546
+ return autonomous ? "deny" : "ask";
4547
+ }
4548
+ function parseApprovalModeSetting(raw) {
4549
+ const value = (raw ?? "").trim().toLowerCase();
4550
+ if (value === "allowlist") return "deny";
4551
+ return value === "ask" || value === "allow" || value === "deny" ? value : "auto";
4194
4552
  }
4195
4553
  var ApprovalPolicy = class {
4196
- mode;
4197
- preApproved;
4554
+ modeOf;
4555
+ entriesOf;
4556
+ parsed = null;
4198
4557
  asker;
4199
4558
  onDecision;
4200
4559
  granted = /* @__PURE__ */ new Set();
@@ -4206,8 +4565,9 @@ var ApprovalPolicy = class {
4206
4565
  // generation it was started under and bails if reset has intervened.
4207
4566
  generation = 0;
4208
4567
  constructor(opts = {}) {
4209
- this.mode = opts.mode ?? "ask";
4210
- this.preApproved = opts.preApproved ?? [];
4568
+ const { mode = "ask", preApproved = [] } = opts;
4569
+ this.modeOf = typeof mode === "function" ? mode : () => mode;
4570
+ this.entriesOf = typeof preApproved === "function" ? preApproved : () => preApproved;
4211
4571
  this.asker = opts.ask;
4212
4572
  this.onDecision = opts.onDecision;
4213
4573
  }
@@ -4218,19 +4578,19 @@ var ApprovalPolicy = class {
4218
4578
  };
4219
4579
  const scopes = approvalScopes(req);
4220
4580
  const key = scopes.join("\n");
4221
- if (scopes.some((s) => this.refused.has(s)) || this.refused.has(key)) return decide(false, "remembered");
4222
- const open = scopes.filter((s) => !this.granted.has(s) && !this.preApproved.some((p) => scopeMatches(p, s)));
4581
+ const list = this.allowlist();
4582
+ const preApproved = (scope, i) => scopeAllowed(list, scope) || i === 0 && (req.kind === "shell_command" && req.allowedBy !== void 0 || req.kind === "mcp_tool" && req.tool !== void 0 && toolAllowed(list, req.tool));
4583
+ const open = scopes.filter((s, i) => !this.granted.has(s) && !preApproved(s, i));
4223
4584
  if (open.length === 0) {
4224
4585
  return decide(true, scopes.every((s) => this.granted.has(s)) ? "remembered" : "pre-approved");
4225
4586
  }
4226
- if (this.mode === "allow") {
4587
+ if (scopes.some((s) => this.refused.has(s)) || this.refused.has(key)) return decide(false, "remembered");
4588
+ const mode = this.modeOf();
4589
+ if (mode === "allow") {
4227
4590
  open.forEach((s) => this.granted.add(s));
4228
4591
  return decide(true, "mode");
4229
4592
  }
4230
- if (this.mode === "deny") {
4231
- this.refused.add(key);
4232
- return decide(false, "mode");
4233
- }
4593
+ if (mode === "deny") return decide(false, "mode");
4234
4594
  if (!this.asker) return decide(false, "no-channel");
4235
4595
  const openKey = open.join("\n");
4236
4596
  const existing = this.inFlight.get(openKey);
@@ -4248,6 +4608,13 @@ var ApprovalPolicy = class {
4248
4608
  }
4249
4609
  return decide(answer2, "asked");
4250
4610
  }
4611
+ /** The standing approvals, parsed from the entries in force now. */
4612
+ allowlist() {
4613
+ const entries = this.entriesOf();
4614
+ const key = entries.join("\n");
4615
+ if (this.parsed?.key !== key) this.parsed = { key, list: parseAllowlist(entries) };
4616
+ return this.parsed.list;
4617
+ }
4251
4618
  /** Scopes the user has granted this session — for display and for persistence. */
4252
4619
  grantedScopes() {
4253
4620
  return [...this.granted];
@@ -5403,8 +5770,10 @@ var BaseConfig = class {
5403
5770
  return nonNegativeInt(process.env.ORDEWELL_CONFLICT_REPAIR_ATTEMPTS, 2);
5404
5771
  }
5405
5772
  get approvalMode() {
5406
- const raw = (process.env.ORDEWELL_APPROVAL_MODE || "").trim().toLowerCase();
5407
- return raw === "allow" || raw === "deny" ? raw : "ask";
5773
+ return parseApprovalModeSetting(process.env.ORDEWELL_APPROVAL_MODE);
5774
+ }
5775
+ get approvalDefaults() {
5776
+ return process.env.ORDEWELL_APPROVAL_DEFAULTS !== "false" && process.env.ORDEWELL_APPROVAL_DEFAULTS !== "0";
5408
5777
  }
5409
5778
  get approvalPreApproved() {
5410
5779
  return (process.env.ORDEWELL_APPROVAL_ALLOW || "").split(/[,\n]/).map((s) => s.trim()).filter(Boolean);
@@ -6144,7 +6513,7 @@ var GitWorktreeIsolation = class {
6144
6513
  return this.admin(workspaceRoot, async () => {
6145
6514
  const scan = await this.scanGroup(workspaceRoot);
6146
6515
  const paths = "paths" in scan ? scan.paths : [SELF_REPO];
6147
- const nested = "paths" in scan ? scan.nested : [];
6516
+ const nested2 = "paths" in scan ? scan.nested : [];
6148
6517
  const run = { id: this.mintRunId(), workspaceRoot, repos: [], shared: [], sharedRepos: [], tasks: {} };
6149
6518
  for (const repoPath of paths) {
6150
6519
  const repo = await this.startRepo(run, repoPath);
@@ -6152,7 +6521,7 @@ var GitWorktreeIsolation = class {
6152
6521
  else run.sharedRepos.push(repoPath);
6153
6522
  }
6154
6523
  if (run.repos.length === 0) throw new Error(`No repository could be isolated: ${run.sharedRepos.join(", ")}`);
6155
- run.shared = paths.includes(SELF_REPO) ? nested : this.sharedPaths(workspaceRoot, run.repos.map((r) => r.path));
6524
+ run.shared = paths.includes(SELF_REPO) ? nested2 : this.sharedPaths(workspaceRoot, run.repos.map((r) => r.path));
6156
6525
  return run;
6157
6526
  });
6158
6527
  }
@@ -7435,6 +7804,7 @@ function researchPhaseBlock(harnessMode) {
7435
7804
  const toolSpecific = harnessMode ? [
7436
7805
  "- Explore the repository with your own tools until you understand its architecture",
7437
7806
  "- You are planning, not implementing: do NOT edit, create, or delete any file, and do not run commands that change the workspace",
7807
+ "- Run commands \u2014 git, gh, cloud CLIs, the project's own tooling \u2014 with the run_command tool, not a shell of your own. MCP tools the user configured (issue trackers, todo lists) are yours to read from too. Reads run at once; anything else is approved or refused by the user's settings. When something the plan needs to change is refused, add it to the plan as an ops task instead",
7438
7808
  "- If you delegate exploration to your own agents, WAIT for their results inside this reply. Do NOT launch them in the background or async and end your turn saying you will report back later: your turn ending is what hands the conversation back to the user, and anything you say after it never reaches them."
7439
7809
  ] : [
7440
7810
  "- Use list_dir with depth=2-3 for a tree overview",
@@ -9744,6 +10114,9 @@ var listRunnersInput = z.object({});
9744
10114
  var listModelsInput = z.object({
9745
10115
  runner: z.string().min(1).describe("A runner id from list_runners.")
9746
10116
  });
10117
+ var runCommandInput = z.object({
10118
+ command: z.string().min(1).describe("One shell command line, run from the workspace root.")
10119
+ });
9747
10120
  var loadSkillInput = z.object({
9748
10121
  name: z.string().min(1).describe("The name of an Ordewell planner skill to load.")
9749
10122
  });
@@ -9883,6 +10256,13 @@ var PLANNER_TOOLS = [
9883
10256
  (h) => h.taskOutput?.bind(h),
9884
10257
  PLANNER_READ_ONLY
9885
10258
  ),
10259
+ tool(
10260
+ "run_command",
10261
+ "Run a shell command for research: git, gh, az, kubectl, the project's own tooling, anything you need to look at. Read-only commands run at once; others ask the user, once per command family; commands that write files are refused. Use it instead of a shell of your own.",
10262
+ runCommandInput,
10263
+ (h) => h.runCommand?.bind(h),
10264
+ PLANNER_READ_ONLY
10265
+ ),
9886
10266
  tool(
9887
10267
  "load_skill",
9888
10268
  "Load an Ordewell planner skill by name. Returns its instructions; only model-invocable planner skills can be loaded.",
@@ -12370,6 +12750,7 @@ var SETTINGS_ENV_REFUSED = [
12370
12750
  "ORDEWELL_AUTONOMOUS_MODE",
12371
12751
  "ORDEWELL_APPROVAL_MODE",
12372
12752
  "ORDEWELL_APPROVAL_ALLOW",
12753
+ "ORDEWELL_APPROVAL_DEFAULTS",
12373
12754
  // Where settings are persisted.
12374
12755
  "ORDEWELL_SETTINGS_PATH",
12375
12756
  // Runtime options and dynamic loaders.
@@ -12968,6 +13349,32 @@ function killTree(proc, deps = {}) {
12968
13349
  // src/services/harness/openCodeOrdewell.ts
12969
13350
  var NAMES = prefixedToolNames(`${ORDEWELL_MCP_SERVER_NAME}_`);
12970
13351
  var RULE = NAMES.toolName("*");
13352
+ var OPENCODE_PLANNER_PERMISSION = {
13353
+ "*": "ask",
13354
+ read: "allow",
13355
+ glob: "allow",
13356
+ grep: "allow",
13357
+ list: "allow",
13358
+ lsp: "allow",
13359
+ skill: "allow",
13360
+ todoread: "allow",
13361
+ websearch: "allow",
13362
+ codesearch: "allow",
13363
+ webfetch: "ask",
13364
+ external_directory: "ask",
13365
+ edit: "deny",
13366
+ write: "deny",
13367
+ patch: "deny",
13368
+ apply_patch: "deny",
13369
+ multiedit: "deny",
13370
+ bash: "deny",
13371
+ task: "deny",
13372
+ question: "deny",
13373
+ todowrite: "deny",
13374
+ shell: "deny",
13375
+ subagent: "deny",
13376
+ browser: "deny"
13377
+ };
12971
13378
  var OPENCODE_CONFIG_VARIABLE = "OPENCODE_CONFIG_CONTENT";
12972
13379
  function isRecord(value) {
12973
13380
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -13005,7 +13412,7 @@ function withoutParentOrdewell(content) {
13005
13412
  const kept = withoutOrdewell(parsed);
13006
13413
  return JSON.stringify(kept) === JSON.stringify(parsed) ? content : JSON.stringify(kept);
13007
13414
  }
13008
- function mergeOrdewellConfig(existing, mcp) {
13415
+ function mergeOrdewellConfig(existing, mcp, role) {
13009
13416
  let base = {};
13010
13417
  if (existing?.trim()) {
13011
13418
  let parsed;
@@ -13017,10 +13424,14 @@ function mergeOrdewellConfig(existing, mcp) {
13017
13424
  if (!isRecord(parsed)) return null;
13018
13425
  base = withoutOrdewell(parsed);
13019
13426
  }
13427
+ if (role === "planner") {
13428
+ delete base.permission;
13429
+ delete base.permissions;
13430
+ }
13020
13431
  const policy = typeof base.permission === "string" ? { "*": base.permission } : base.permission;
13021
13432
  const ours = {
13022
- mcp: { [mcp.name]: { type: "remote", url: mcp.url, headers: mcp.headers, enabled: true } },
13023
- permission: { [RULE]: "allow" }
13433
+ ...mcp ? { mcp: { [mcp.name]: { type: "remote", url: mcp.url, headers: mcp.headers, enabled: true } } } : {},
13434
+ permission: { ...role === "planner" ? OPENCODE_PLANNER_PERMISSION : {}, ...mcp ? { [RULE]: "allow" } : {} }
13024
13435
  };
13025
13436
  return JSON.stringify(deepMerge({ ...base, ...policy === void 0 ? {} : { permission: policy } }, ours));
13026
13437
  }
@@ -13465,7 +13876,24 @@ var DISALLOWED_TOOLS = [
13465
13876
  "EnterPlanMode",
13466
13877
  "ExitPlanMode"
13467
13878
  ];
13468
- var PLANNER_PERMISSION_MODE = "dontAsk";
13879
+ var PLANNER_PERMISSION_MODE = "default";
13880
+ var PATH_TOOLS = {
13881
+ Read: { field: "file_path", directory: false },
13882
+ Grep: { field: "path", directory: true },
13883
+ Glob: { field: "path", directory: true },
13884
+ LS: { field: "path", directory: true }
13885
+ };
13886
+ function plannerAsk(name, input, server) {
13887
+ if (name.startsWith("mcp__")) {
13888
+ const tool2 = name.split("__").slice(2).join("__");
13889
+ return { kind: "mcp", scope: name, tool: tool2 || name, ...server ? { server } : {} };
13890
+ }
13891
+ if (name === "WebFetch" && typeof input.url === "string") return { kind: "fetch", url: input.url };
13892
+ const pathTool = PATH_TOOLS[name];
13893
+ const target = pathTool ? input[pathTool.field] : void 0;
13894
+ if (pathTool && typeof target === "string") return { kind: "path", path: target, directory: pathTool.directory };
13895
+ return { kind: "other" };
13896
+ }
13469
13897
  var TASK_DISALLOWED_TOOLS = ["AskUserQuestion"];
13470
13898
  var PROTOCOL_ARGS = [
13471
13899
  "-p",
@@ -13575,6 +14003,8 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
13575
14003
  const args = [
13576
14004
  ...PROTOCOL_ARGS,
13577
14005
  // The read-only guarantee, enforced at spawn rather than by prompt.
14006
+ "--permission-prompt-tool",
14007
+ "stdio",
13578
14008
  "--permission-mode",
13579
14009
  PLANNER_PERMISSION_MODE,
13580
14010
  "--disallowedTools",
@@ -13761,32 +14191,20 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
13761
14191
  const subagentId = msg.parent_tool_use_id ?? void 0;
13762
14192
  switch (msg.type) {
13763
14193
  // The control channel: Claude asks whether a tool may run when its mode
13764
- // cannot decide alone. A read-only planner answers "deny", every time —
13765
- // and must answer, because an unacknowledged request stalls the turn.
13766
- // A task's request is left open for someone to answer (ADR-0018, A1).
14194
+ // cannot decide alone. The request is left open — a task's for someone
14195
+ // to answer (ADR-0018, A1), a planner's for its envelope (ADR-0026) —
14196
+ // and must be answered, because an unacknowledged request stalls the turn.
13767
14197
  case "control_request": {
13768
14198
  if (msg.request?.subtype !== "can_use_tool") return;
13769
14199
  const input = msg.request.input ?? {};
13770
14200
  const id = msg.request_id ?? "";
13771
- if (this.role === "planner") {
13772
- this.writeLine({
13773
- type: "control_response",
13774
- response: {
13775
- subtype: "success",
13776
- request_id: msg.request_id,
13777
- response: { behavior: "deny", message: "The Ordewell planner is read-only. Mutation belongs to the runners that execute the plan." }
13778
- }
13779
- });
13780
- emit({ type: "permission_request", id, name: msg.request.tool_name ?? "unknown", detail: JSON.stringify(input) });
13781
- return;
13782
- }
13783
14201
  const name = msg.request.tool_name ?? "unknown";
13784
14202
  if (this.givenOrdewell && CLAUDE_ORDEWELL.isOrdewellAsk(name)) {
13785
14203
  this.writeLine({ type: "control_response", response: { subtype: "success", request_id: id, response: { behavior: "allow", updatedInput: input } } });
13786
14204
  emit({ type: "permission_request", id, name, detail: JSON.stringify(input), input, decided: { decision: "allow" } });
13787
14205
  return;
13788
14206
  }
13789
- const suggestions = msg.request.permission_suggestions ?? [];
14207
+ const suggestions = this.role === "task" ? msg.request.permission_suggestions ?? [] : [];
13790
14208
  this.openPermissions.set(id, { input, suggestions });
13791
14209
  emit({
13792
14210
  type: "permission_request",
@@ -13795,7 +14213,8 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
13795
14213
  detail: JSON.stringify(input),
13796
14214
  input,
13797
14215
  suggestions,
13798
- ...msg.request.tool_use_id ? { toolUseId: msg.request.tool_use_id } : {}
14216
+ ...msg.request.tool_use_id ? { toolUseId: msg.request.tool_use_id } : {},
14217
+ ...this.role === "planner" ? { ask: plannerAsk(name, input, msg.request.mcp_server?.name) } : {}
13799
14218
  });
13800
14219
  return;
13801
14220
  }
@@ -14087,6 +14506,20 @@ var CODEX_ORDEWELL = {
14087
14506
  `They are not in your tool list up front. Find them with the tool discovery you have (the \`exec\` tool's \`ALL_TOOLS\` list) and call them by their full names, ${quoted2(this.toolNames("task"))}.`,
14088
14507
  `Look for them before you finish; the task is reported complete through \`${TASK_COMPLETE_TOOL}\`.`
14089
14508
  ].join(" ");
14509
+ },
14510
+ /**
14511
+ * The same discovery note for a planner, which also needs to know that its
14512
+ * commands go through `run_command`: Codex's own shell is sandboxed without
14513
+ * network, so `gh` or a cloud CLI fails there, and a model that tried its
14514
+ * shell first concludes the command cannot run.
14515
+ */
14516
+ plannerInstructions() {
14517
+ const tools = ordewellToolNames("planner");
14518
+ return [
14519
+ `You have ${COUNT_WORDS[tools.length] ?? tools.length} tools from the \`${ORDEWELL_MCP_SERVER_NAME}\` MCP server: ${quoted2(tools)}.`,
14520
+ `They are not in your tool list up front. Find them with the tool discovery you have (the \`exec\` tool's \`ALL_TOOLS\` list) and call them by their full names, ${quoted2(this.toolNames("planner"))}.`,
14521
+ `Run shell commands with \`${this.toolName("run_command")}\`, not your own shell: yours is sandboxed without network, so git remotes, gh and cloud CLIs fail there.`
14522
+ ].join(" ");
14090
14523
  }
14091
14524
  };
14092
14525
 
@@ -14274,6 +14707,8 @@ var CodexAdapter = class extends StdioAgentAdapter {
14274
14707
  interruptedTurnIds = /* @__PURE__ */ new Set();
14275
14708
  /** A task's approval requests still waiting for an answer, by the request id as a string. */
14276
14709
  openPermissions = /* @__PURE__ */ new Map();
14710
+ /** The tool each MCP server's latest call is for, which names the tool an approval asks about when its question does not. */
14711
+ mcpCallsInProgress = /* @__PURE__ */ new Map();
14277
14712
  /**
14278
14713
  * The files each in-flight `fileChange` item touches. Its approval request
14279
14714
  * names only the item, and a person deciding needs to see the files.
@@ -14381,14 +14816,18 @@ ${this.exitMessage()}`);
14381
14816
  return {
14382
14817
  ...common,
14383
14818
  sandbox: "read-only",
14384
- approvalPolicy: "never",
14819
+ // `on-request`, so a user's MCP tool reaches the planner's envelope as an approval
14820
+ // (ADR-0026); under `never` Codex fails the call without asking. Commands and file
14821
+ // changes it asks about are still declined: the sandbox holds, and commands go
14822
+ // through Ordewell's `run_command`.
14823
+ approvalPolicy: "on-request",
14385
14824
  ...threadConfig,
14386
14825
  // `developerInstructions` layers on top of Codex's own base prompt, the
14387
14826
  // way Claude Code's `--append-system-prompt` does. `baseInstructions`
14388
14827
  // replaces it — which takes Codex's description of its own tools with
14389
14828
  // it, and a planner that has forgotten it can read the workspace
14390
14829
  // answers from a web search instead.
14391
- developerInstructions: opts?.systemPrompt
14830
+ developerInstructions: [opts?.systemPrompt, opts?.mcp ? CODEX_ORDEWELL.plannerInstructions() : ""].filter(Boolean).join("\n\n") || void 0
14392
14831
  };
14393
14832
  }
14394
14833
  /**
@@ -14492,7 +14931,7 @@ ${this.exitMessage()}`);
14492
14931
  if (!open || !this.process) return false;
14493
14932
  this.openPermissions.delete(id);
14494
14933
  this.rpc.respond(open.requestId, TASK_APPROVALS[open.method].answer(decision.decision, open.params));
14495
- const note = decision.decision === "deny" ? decision.note?.trim() : void 0;
14934
+ const note = decision.decision === "deny" && this.role === "task" ? decision.note?.trim() : void 0;
14496
14935
  if (note && this.turn?.id) this.requestSteer(this.turn.id, note);
14497
14936
  return true;
14498
14937
  }
@@ -14778,10 +15217,12 @@ ${this.exitMessage()}`);
14778
15217
  */
14779
15218
  answerServerRequest(msg, emit) {
14780
15219
  const method = msg.method;
14781
- if (CODEX_ORDEWELL.isOrdewellAsk({ method, params: msg.params ?? {} })) {
15220
+ const params = msg.params ?? {};
15221
+ if (CODEX_ORDEWELL.isOrdewellAsk({ method, params })) {
14782
15222
  this.rpc.respond(msg.id, { action: "accept", content: {} });
14783
15223
  return;
14784
15224
  }
15225
+ if (this.holdMcpToolApproval(msg, method, params, emit)) return;
14785
15226
  const result = DECLINE_RESULTS[method];
14786
15227
  if (result) {
14787
15228
  this.rpc.respond(msg.id, result);
@@ -14793,9 +15234,30 @@ ${this.exitMessage()}`);
14793
15234
  type: "permission_request",
14794
15235
  id: String(msg.id ?? ""),
14795
15236
  name: method.split("/").pop() ?? method,
14796
- detail: JSON.stringify(msg.params ?? {})
15237
+ detail: JSON.stringify(msg.params ?? {}),
15238
+ decided: { decision: "deny" }
14797
15239
  });
14798
15240
  }
15241
+ /**
15242
+ * A planner's approval for a tool of the user's own MCP server, held for the
15243
+ * planner's envelope to answer (ADR-0026). Codex names the server; the tool
15244
+ * is in its question, or else the call it last started on that server.
15245
+ */
15246
+ holdMcpToolApproval(msg, method, params, emit) {
15247
+ if (method !== "mcpServer/elicitation/request") return false;
15248
+ const meta = params._meta;
15249
+ const server = params.serverName;
15250
+ if (meta?.codex_approval_kind !== "mcp_tool_call" || typeof server !== "string") return false;
15251
+ const asked = typeof params.message === "string" ? /run tool "([^"]+)"/.exec(params.message)?.[1] : void 0;
15252
+ const tool2 = asked ?? this.mcpCallsInProgress.get(server);
15253
+ if (!tool2) return false;
15254
+ const id = String(msg.id);
15255
+ const input = typeof meta.tool_params === "object" && meta.tool_params !== null ? meta.tool_params : {};
15256
+ const scope = `mcp__${server}__${tool2}`;
15257
+ this.openPermissions.set(id, { requestId: msg.id, method, params });
15258
+ emit({ type: "permission_request", id, name: scope, detail: JSON.stringify(input), input, ask: { kind: "mcp", scope, tool: tool2, server } });
15259
+ return true;
15260
+ }
14799
15261
  /** A tool item entering `inProgress` — announce the call so the timeline moves. */
14800
15262
  emitItemStart(item, emit, subagentId) {
14801
15263
  if (!item?.id) return;
@@ -14807,6 +15269,7 @@ ${this.exitMessage()}`);
14807
15269
  emit({ type: "tool_call", id: item.id, name: "shell", args: { command: item.command, cwd: item.cwd }, ...subagentId ? { subagentId } : {} });
14808
15270
  return;
14809
15271
  case "mcpToolCall":
15272
+ if (item.server && item.tool) this.mcpCallsInProgress.set(item.server, item.tool);
14810
15273
  emit({ type: "tool_call", id: item.id, name: shownToolName(item), args: item.arguments ?? {}, ...subagentId ? { subagentId } : {} });
14811
15274
  return;
14812
15275
  case "dynamicToolCall":
@@ -14979,6 +15442,55 @@ import { randomBytes as randomBytes6 } from "crypto";
14979
15442
  // src/services/harness/openCodeTransport.ts
14980
15443
  import { randomBytes as randomBytes5 } from "crypto";
14981
15444
  var STREAM_CONNECT_TIMEOUT_MS = 5e3;
15445
+ var OPENCODE_BUILTINS = /* @__PURE__ */ new Set([
15446
+ "bash",
15447
+ "edit",
15448
+ "write",
15449
+ "patch",
15450
+ "apply_patch",
15451
+ "multiedit",
15452
+ "read",
15453
+ "glob",
15454
+ "grep",
15455
+ "list",
15456
+ "lsp",
15457
+ "task",
15458
+ "question",
15459
+ "todowrite",
15460
+ "todoread",
15461
+ "skill",
15462
+ "websearch",
15463
+ "codesearch",
15464
+ "webfetch",
15465
+ "external_directory",
15466
+ "doom_loop",
15467
+ "invalid",
15468
+ "batch",
15469
+ "shell",
15470
+ "subagent",
15471
+ "browser",
15472
+ "execute",
15473
+ "search",
15474
+ "opencode_read_mcp_resource",
15475
+ "opencode_list_mcp_resources"
15476
+ ]);
15477
+ function openCodePlannerAsk(permission, details, servers) {
15478
+ const meta = details.metadata ?? {};
15479
+ const pattern = details.patterns?.[0];
15480
+ if (permission === "webfetch") {
15481
+ const url = typeof meta.url === "string" ? meta.url : pattern;
15482
+ return url ? { kind: "fetch", url } : { kind: "other" };
15483
+ }
15484
+ if (permission === "external_directory") {
15485
+ if (typeof meta.filepath === "string") return { kind: "path", path: meta.filepath, directory: false };
15486
+ const dir = typeof meta.parentDir === "string" ? meta.parentDir : pattern?.replace(/[\\/]\*$/, "");
15487
+ return dir ? { kind: "path", path: dir, directory: true } : { kind: "other" };
15488
+ }
15489
+ if (OPENCODE_BUILTINS.has(permission)) return { kind: "other" };
15490
+ const server = servers.filter((s) => permission.startsWith(`${s}_`)).sort((a, b) => b.length - a.length)[0];
15491
+ if (!server) return { kind: "other" };
15492
+ return { kind: "mcp", scope: permission, tool: permission.slice(server.length + 1), server };
15493
+ }
14982
15494
  function splitModelId(id) {
14983
15495
  const slash = id.indexOf("/");
14984
15496
  if (slash <= 0 || slash === id.length - 1) return null;
@@ -15072,7 +15584,6 @@ var PendingSteers = class {
15072
15584
  }
15073
15585
  };
15074
15586
  var ALLOW = { decision: "allow" };
15075
- var DENY = { decision: "deny" };
15076
15587
  var OpenCodePermissions = class {
15077
15588
  constructor(reply2, ordewell) {
15078
15589
  this.reply = reply2;
@@ -15083,23 +15594,8 @@ var OpenCodePermissions = class {
15083
15594
  /** Requests waiting for an answer, by request id, with the session that asked. */
15084
15595
  open = /* @__PURE__ */ new Map();
15085
15596
  /**
15086
- * A planner's request (T1): refused, which is the same "absent answer is a
15087
- * denial" invariant ADR-0008 states for Ordewell's own tools. The refusal is
15088
- * announced so the timeline shows the planner reaching for something it may
15089
- * not have. The planner's own submission path, which the allow rule should
15090
- * already have settled, is the one request it may not be refused.
15091
- */
15092
- refuse(request, sessionId, seen, onEvent) {
15093
- if (!firstSight(request.id, seen)) return;
15094
- if (this.isOrdewellTool(request.name)) {
15095
- this.send(request.id, sessionId, ALLOW);
15096
- return;
15097
- }
15098
- onEvent(request);
15099
- this.send(request.id, sessionId, DENY);
15100
- }
15101
- /**
15102
- * A task's request. Under a mode whose manifest sets `approvals: auto` it is
15597
+ * A task's request, or a planner's (with `autoApprove` false) for the
15598
+ * planner's envelope to answer (ADR-0026). Under a mode whose manifest sets `approvals: auto` it is
15103
15599
  * answered at once with what `opencode run --auto` answers, so the same plan
15104
15600
  * follows the manifest (ADR-0001), and announced already
15105
15601
  * decided so the log still shows it. Any other mode leaves it open for an
@@ -15278,10 +15774,7 @@ async function streamEvents(connect, signal, onFrame, onConnected, onActivity) {
15278
15774
  // src/services/harness/OpenCodeV2.ts
15279
15775
  var ACTIVE_POLL_INTERVAL_MS = 1e3;
15280
15776
  var IDLE_POLLS_BEFORE_START = 3;
15281
- var PLANNER_RULES = [
15282
- { action: "question", resource: "*", effect: "deny" },
15283
- { action: "edit", resource: "*", effect: "deny" }
15284
- ];
15777
+ var PLANNER_RULES = Object.entries(OPENCODE_PLANNER_PERMISSION).map(([action, effect]) => ({ action, resource: "*", effect }));
15285
15778
  var TASK_RULES = [{ action: "question", resource: "*", effect: "deny" }];
15286
15779
  function newTurnState() {
15287
15780
  return { ...streamTurn(), toolNames: /* @__PURE__ */ new Map(), stepModels: /* @__PURE__ */ new Map(), briefs: /* @__PURE__ */ new Map(), streamedMessages: /* @__PURE__ */ new Set() };
@@ -15328,6 +15821,7 @@ var OpenCodeV2 = class {
15328
15821
  return this.sessionId;
15329
15822
  }
15330
15823
  async start() {
15824
+ await this.exposeOrdewellTools();
15331
15825
  const resume = this.opts.resumeSessionId;
15332
15826
  if (resume) {
15333
15827
  const existing = await this.host.json("GET", `/api/session/${resume}`).catch(() => null);
@@ -15349,6 +15843,19 @@ var OpenCodeV2 = class {
15349
15843
  this.sessionId = created.data.id;
15350
15844
  await this.applyInstructions();
15351
15845
  }
15846
+ /**
15847
+ * 2.x lists an MCP server's tools inside its code-mode `execute` tool, where
15848
+ * a model told to call `task_complete` does not look for it. The server is
15849
+ * replaced at runtime with `codemode: false`, which lists its tools as tools
15850
+ * of their own: the config it started with is in the 1.x shape both versions
15851
+ * read, and that shape drops the setting. If the route is gone, the tools
15852
+ * are still reachable through `execute`.
15853
+ */
15854
+ async exposeOrdewellTools() {
15855
+ const mcp = this.opts.mcp;
15856
+ if (!mcp) return;
15857
+ await this.host.request("PUT", `/api/experimental/mcp/${mcp.name}`, { config: { type: "remote", url: mcp.url, headers: mcp.headers, codemode: false } }).catch(() => void 0);
15858
+ }
15352
15859
  /** Allow the Ordewell server's tools, so a call never waits on a person (ADR-0022, S3). */
15353
15860
  ordewellRules() {
15354
15861
  return this.opts.mcp ? OPENCODE_ORDEWELL.sessionRules() : [];
@@ -15609,12 +16116,14 @@ var OpenCodeV2 = class {
15609
16116
  ...ask.source?.id ? { toolUseId: ask.source.id } : {}
15610
16117
  };
15611
16118
  }
15612
- /** A planner's request is refused; a task's is answered at once or left open for a card. */
16119
+ /** A planner's request is held for its envelope (ADR-0026); a task's is answered at once or left open for a card. */
15613
16120
  onPermissionAsked(ask, session, state, onEvent) {
15614
16121
  const request = this.permissionRequest(ask);
15615
16122
  if (!request) return;
15616
- if (this.role !== "task") this.permissions.refuse(request, session, state.seen, onEvent);
15617
- else this.permissions.ask(request, session, this.opts.kind === "task" && autoApproves(this.opts), state.seen, onEvent);
16123
+ if (this.role !== "task") {
16124
+ const target = openCodePlannerAsk(request.name, { patterns: ask.resources }, this.host.mcpServers?.() ?? []);
16125
+ this.permissions.ask({ ...request, ask: target }, session, false, state.seen, onEvent);
16126
+ } else this.permissions.ask(request, session, this.opts.kind === "task" && autoApproves(this.opts), state.seen, onEvent);
15618
16127
  }
15619
16128
  };
15620
16129
 
@@ -15632,7 +16141,9 @@ var DISABLED_TOOLS = {
15632
16141
  edit: false,
15633
16142
  write: false,
15634
16143
  apply_patch: false,
15635
- todowrite: false
16144
+ todowrite: false,
16145
+ bash: false,
16146
+ task: false
15636
16147
  };
15637
16148
  var TASK_DISABLED_TOOLS = { question: false };
15638
16149
  function describeError(err) {
@@ -15703,6 +16214,8 @@ var OpenCodeAdapter = class {
15703
16214
  taskTurn = null;
15704
16215
  /** An abort was posted during the current task turn, so the idle that follows ends it as interrupted. */
15705
16216
  interruptRequested = false;
16217
+ /** The user's MCP servers, as `/mcp` last listed them: what tells a planner's MCP tool request from a built-in's (ADR-0026). */
16218
+ mcpServers = [];
15706
16219
  permissions = new OpenCodePermissions(
15707
16220
  (id, sessionId, decision) => this.replyPermission(id, sessionId, permissionReply(decision, "reply")),
15708
16221
  () => this.ordewell
@@ -15735,7 +16248,8 @@ var OpenCodeAdapter = class {
15735
16248
  command: "opencode",
15736
16249
  args: ["serve", "--hostname", "127.0.0.1", "--port", "0"],
15737
16250
  env: (workspaceEnv) => {
15738
- const ordewellConfig = opts.mcp ? OPENCODE_ORDEWELL.configContent(workspaceEnv[OPENCODE_CONFIG_VARIABLE] ?? process.env[OPENCODE_CONFIG_VARIABLE], opts.mcp) : null;
16251
+ const existing = workspaceEnv[OPENCODE_CONFIG_VARIABLE] ?? process.env[OPENCODE_CONFIG_VARIABLE];
16252
+ const ordewellConfig = opts.kind === "planner" ? OPENCODE_ORDEWELL.configContent(existing, opts.mcp ?? null, "planner") ?? OPENCODE_ORDEWELL.configContent(void 0, opts.mcp ?? null, "planner") : opts.mcp ? OPENCODE_ORDEWELL.configContent(existing, opts.mcp, "task") : null;
15739
16253
  if (opts.mcp && ordewellConfig === null) {
15740
16254
  console.error("[opencode] OPENCODE_CONFIG_CONTENT is not a JSON object, so the Ordewell tools were not injected.");
15741
16255
  }
@@ -15793,7 +16307,8 @@ var OpenCodeAdapter = class {
15793
16307
  openEvents: (signal) => this.deps.fetch(`${this.baseUrl}/api/event`, { signal, headers: this.headers() }),
15794
16308
  processEnded: this.processEnded,
15795
16309
  isExited: () => this.exited,
15796
- exitMessage: () => this.exitMessage()
16310
+ exitMessage: () => this.exitMessage(),
16311
+ mcpServers: () => this.mcpServers
15797
16312
  }, { ...opts, mcp: this.ordewell ?? void 0 }, this.role());
15798
16313
  await this.v2.start();
15799
16314
  this.sessionId = this.v2.nativeSessionId();
@@ -16259,7 +16774,15 @@ var OpenCodeAdapter = class {
16259
16774
  const sessionId = ask.sessionID ?? this.sessionId ?? "";
16260
16775
  const name = permissionName(ask);
16261
16776
  if (!this.task) {
16262
- this.permissions.refuse({ type: "permission_request", id, name, detail: permissionDetail(ask) }, sessionId, seen, onEvent);
16777
+ this.permissions.ask({
16778
+ type: "permission_request",
16779
+ id,
16780
+ name,
16781
+ detail: permissionDetail(ask),
16782
+ input: ask.metadata ?? {},
16783
+ ...ask.tool?.callID ? { toolUseId: ask.tool.callID } : {},
16784
+ ask: openCodePlannerAsk(name, ask, this.mcpServers)
16785
+ }, sessionId, false, seen, onEvent);
16263
16786
  return;
16264
16787
  }
16265
16788
  const request = {
@@ -16316,10 +16839,21 @@ var OpenCodeAdapter = class {
16316
16839
  if (!ordewell || !this.baseUrl) return false;
16317
16840
  return awaitAttach(async () => {
16318
16841
  if (this.exited) return "failed";
16319
- const servers = await this.json("GET", "/mcp").catch(() => null);
16842
+ const servers = this.v2 ? await this.v2McpStatus() : await this.json("GET", "/mcp").catch(() => null);
16843
+ if (servers) this.mcpServers = Object.keys(servers).filter((name) => name !== ordewell.name);
16320
16844
  return OPENCODE_ORDEWELL.attachState(servers?.[ordewell.name]?.status);
16321
16845
  }, MCP_ATTACH_TIMEOUT_MS3, MCP_STATUS_POLL_MS2);
16322
16846
  }
16847
+ /** 2.x lists its servers as `{ name, status: { status } }`, and only once it has started connecting them. */
16848
+ async v2McpStatus() {
16849
+ const listed2 = await this.json("GET", "/api/mcp").catch(() => null);
16850
+ if (!Array.isArray(listed2?.data)) return null;
16851
+ const servers = {};
16852
+ for (const { name, status } of listed2.data) {
16853
+ if (typeof name === "string") servers[name] = typeof status?.status === "string" ? { status: status.status } : {};
16854
+ }
16855
+ return servers;
16856
+ }
16323
16857
  dispose() {
16324
16858
  if (this.disposed) return;
16325
16859
  this.disposed = true;
@@ -18891,6 +19425,10 @@ function plannerToolHandler(host) {
18891
19425
  }
18892
19426
  return { text: skill.content };
18893
19427
  },
19428
+ async runCommand({ command }, { signal }) {
19429
+ const outcome = await host.runCommand(command, signal);
19430
+ return { text: outcome.output || (outcome.success ? "(no output)" : "The command failed with no output."), ...outcome.success ? {} : { isError: true } };
19431
+ },
18894
19432
  async listRunners() {
18895
19433
  const catalog = await host.liveCatalog();
18896
19434
  return answer({
@@ -19140,6 +19678,47 @@ var TurnStream = class {
19140
19678
  }
19141
19679
  };
19142
19680
 
19681
+ // src/services/harness/plannerPermissions.ts
19682
+ var READ_ONLY_DENIAL = "The Ordewell planner is read-only: it changes no files. Use run_command for commands; changes belong to the plan's tasks.";
19683
+ var OPS_HINT = "Continue without it. If it would change something outside the repository, add it to the plan as an ops task; if it only reads, ask the user to approve it or add it to the planner allowlist.";
19684
+ var ALLOW2 = { decision: "allow" };
19685
+ var SUBJECT_MAX_CHARS = 400;
19686
+ function deny(note) {
19687
+ return { decision: "deny", note };
19688
+ }
19689
+ async function decidePlannerPermission(ask, input, gate) {
19690
+ if (!ask || ask.kind === "other") return deny(READ_ONLY_DENIAL);
19691
+ if (!gate) return deny("Nobody can approve this here. Continue without it.");
19692
+ switch (ask.kind) {
19693
+ case "mcp": {
19694
+ const args = JSON.stringify(input);
19695
+ const subject = `${ask.server ?? "MCP"} \xB7 ${ask.tool} ${args.length > SUBJECT_MAX_CHARS ? `${args.slice(0, SUBJECT_MAX_CHARS)}\u2026` : args}`;
19696
+ const granted = await gate.approval.request({
19697
+ kind: "mcp_tool",
19698
+ subject,
19699
+ scope: ask.scope,
19700
+ tool: ask.tool,
19701
+ detail: `The planner wants to use "${ask.tool}"${ask.server ? ` from ${ask.server}` : ""}. It may change things outside the repository.`
19702
+ });
19703
+ return granted ? ALLOW2 : deny(`Not approved: ${ask.scope}. ${OPS_HINT}`);
19704
+ }
19705
+ case "fetch":
19706
+ if (!gate.fetcher) return deny("This fetch cannot be approved here.");
19707
+ return await gate.fetcher.confirm(ask.url) ? ALLOW2 : deny(`Fetch denied (blocked host or not approved): ${ask.url}. ${OPS_HINT}`);
19708
+ case "path": {
19709
+ const { abs, inside } = resolveWithin(gate.workspaceRoot, ask.path);
19710
+ if (inside) return ALLOW2;
19711
+ const granted = await gate.approval.request({
19712
+ kind: "external_path",
19713
+ subject: abs,
19714
+ scope: grantScopeFor(abs, ask.directory ? "directory" : "file"),
19715
+ detail: `Planner research wants to read ${abs}, outside the workspace (${gate.workspaceRoot}).`
19716
+ });
19717
+ return granted ? ALLOW2 : deny(`Access denied: "${abs}" is outside the workspace and was not approved.`);
19718
+ }
19719
+ }
19720
+ }
19721
+
19143
19722
  // src/services/harness/CliAgentAiService.ts
19144
19723
  var MAX_AGENT_WAITS = 2;
19145
19724
  function waitForAgentsPrompt(running) {
@@ -19183,6 +19762,7 @@ var CliAgentAiService = class {
19183
19762
  * every session boundary — nothing from one goal may reach the next.
19184
19763
  */
19185
19764
  lastNativeSessionId = null;
19765
+ /** `gate` is what the agent's own tool requests are decided against; absent, only Ordewell's tools are allowed (ADR-0026). */
19186
19766
  conversation = null;
19187
19767
  mcpServer;
19188
19768
  /** The planner token the running process was spawned with; revoked with the process (ADR-0022, A3). */
@@ -19251,7 +19831,8 @@ var CliAgentAiService = class {
19251
19831
  };
19252
19832
  const tools = req.plannerTools;
19253
19833
  await this.startAdapter(startOptions, tools);
19254
- this.conversation = { startOptions, tools };
19834
+ const gate = req.approval ? { approval: req.approval, fetcher: req.fetcher, workspaceRoot: startOptions.cwd } : void 0;
19835
+ this.conversation = { startOptions, tools, gate };
19255
19836
  const opening = this.openingMessage(req);
19256
19837
  return this.runConversation(opening, req.onProgress, req.signal);
19257
19838
  }
@@ -19341,6 +19922,8 @@ var CliAgentAiService = class {
19341
19922
  segmentId = uuidv44();
19342
19923
  };
19343
19924
  const streamedThinking = /* @__PURE__ */ new Set();
19925
+ const deniedCalls = /* @__PURE__ */ new Set();
19926
+ const gate = this.conversation?.gate;
19344
19927
  const subagentUsage = /* @__PURE__ */ new Map();
19345
19928
  let error;
19346
19929
  let stepIndex = 0;
@@ -19395,7 +19978,7 @@ var CliAgentAiService = class {
19395
19978
  return;
19396
19979
  }
19397
19980
  case "tool_result":
19398
- settle(event.id, event.output, event.success, event.success ? "success" : "failure", event.subagentId);
19981
+ settle(event.id, event.output, event.success, event.success ? "success" : deniedCalls.has(event.id) ? "denied" : "failure", event.subagentId);
19399
19982
  return;
19400
19983
  case "usage": {
19401
19984
  const { subagentId } = event.record;
@@ -19423,18 +20006,28 @@ var CliAgentAiService = class {
19423
20006
  backgroundAgents++;
19424
20007
  return;
19425
20008
  case "permission_request": {
19426
- const mapped = mapAgentTool(event.name);
19427
- const args = JSON.stringify({ detail: event.detail });
19428
- if (!pendingCalls.has(event.id)) {
19429
- pendingCalls.set(event.id, { tool: mapped.tool, toolLabel: mapped.toolLabel, args });
19430
- onProgress({ type: "tool_call", tool: mapped.tool, toolLabel: mapped.toolLabel, toolArgs: args, toolCallId: event.id });
20009
+ const announceDenial = (output) => {
20010
+ const mapped = mapAgentTool(event.name);
20011
+ const args = JSON.stringify({ detail: event.detail });
20012
+ if (!pendingCalls.has(event.id)) {
20013
+ pendingCalls.set(event.id, { tool: mapped.tool, toolLabel: mapped.toolLabel, args });
20014
+ onProgress({ type: "tool_call", tool: mapped.tool, toolLabel: mapped.toolLabel, toolArgs: args, toolCallId: event.id });
20015
+ }
20016
+ settle(event.id, output, false, "denied");
20017
+ };
20018
+ if (!event.decided && adapter.answerPermission) {
20019
+ const { id, toolUseId } = event;
20020
+ void decidePlannerPermission(event.ask, event.input ?? {}, gate).catch(() => ({ decision: "deny" })).then((decision) => {
20021
+ if (decision.decision === "deny") {
20022
+ if (toolUseId) deniedCalls.add(toolUseId);
20023
+ else announceDenial(`Access denied: "${event.name}" was refused. ${decision.note ?? ""}`.trim());
20024
+ }
20025
+ adapter.answerPermission?.(id, decision);
20026
+ });
20027
+ return;
19431
20028
  }
19432
- settle(
19433
- event.id,
19434
- `Access denied: the planner runs read-only, so "${event.name}" was refused. Mutation belongs to the runners that execute the plan.`,
19435
- false,
19436
- "denied"
19437
- );
20029
+ if (event.decided?.decision !== "deny") return;
20030
+ announceDenial(`Access denied: the planner runs read-only, so "${event.name}" was refused. Mutation belongs to the runners that execute the plan.`);
19438
20031
  return;
19439
20032
  }
19440
20033
  case "error":
@@ -20804,7 +21397,7 @@ var HttpWebFetcher = class {
20804
21397
  }
20805
21398
  async fetch(url) {
20806
21399
  if (!await this.confirm(url)) {
20807
- return { success: false, output: `Fetch denied (blocked host or not approved): ${url}`, truncated: false };
21400
+ return { success: false, output: `Fetch denied (blocked host or not approved): ${url}. Continue without it, or ask the user to approve this origin or add it to the planner allowlist.`, truncated: false };
20808
21401
  }
20809
21402
  return this.get(url, "Fetch");
20810
21403
  }
@@ -21818,8 +22411,9 @@ function createSession(deps) {
21818
22411
  checkpointQuestion: (taskId) => orchestrator.getCheckpointQuestion(taskId)
21819
22412
  });
21820
22413
  const approvalPolicy = new ApprovalPolicy({
21821
- mode: deps.config.approvalMode,
21822
- preApproved: deps.config.approvalPreApproved,
22414
+ // Read per request, so a change of autonomy level or allowlist applies to the next call.
22415
+ mode: () => effectiveApprovalMode(deps.config.approvalMode, deps.config.autonomousMode),
22416
+ preApproved: () => [...deps.config.approvalDefaults ? DEFAULT_PLANNER_ALLOWLIST : [], ...deps.config.approvalPreApproved],
21823
22417
  ask: (req) => approvals.ask(req),
21824
22418
  // The interactive path (`asked`) already broadcasts approval_request +
21825
22419
  // approval_settled; only the silent sources need a signal, or a
@@ -21922,7 +22516,8 @@ var Session = class {
21922
22516
  read: (signature, answer2) => this.conversation.read(signature, answer2),
21923
22517
  liveOutput: (taskId, opts) => this.orchestrator.getLiveOutput(taskId, opts),
21924
22518
  lastAttempt: (taskId) => lastAttemptDigest(this.taskLogLocation, taskId),
21925
- taskSkills: () => this.workspaceSkills()
22519
+ taskSkills: () => this.workspaceSkills(),
22520
+ runCommand: (command, signal) => this.fsAdapter.bash(command, signal)
21926
22521
  });
21927
22522
  constructor(parts) {
21928
22523
  this.config = parts.config;
@@ -22379,6 +22974,7 @@ var Session = class {
22379
22974
  contextWindow: this.modelResolver.contextWindowFor?.(this.config.orchestratorModel),
22380
22975
  fs: this.fsAdapter,
22381
22976
  fetcher: this.fetcher,
22977
+ approval: this.approvalPolicy,
22382
22978
  plannerTools: { sessionId: this.sessionId, handler: this.plannerTools },
22383
22979
  skills: this.workspaceSkills().listSkills()
22384
22980
  };
@@ -24009,6 +24605,7 @@ export {
24009
24605
  DAEMON_SUBPROTOCOL,
24010
24606
  DAEMON_TOKEN_SUBPROTOCOL_PREFIX,
24011
24607
  DEFAULT_MAX_PARALLEL,
24608
+ DEFAULT_PLANNER_ALLOWLIST,
24012
24609
  DEFAULT_RUNNERS,
24013
24610
  DENY_ALL,
24014
24611
  EMPTY_CONVERSATION,
@@ -24145,6 +24742,7 @@ export {
24145
24742
  coerceAssignments,
24146
24743
  collectDirectDependencyOutputs,
24147
24744
  collectProviderCredentials,
24745
+ commandAllowedBy,
24148
24746
  composeAugmentedPrompt,
24149
24747
  composeContinuationPrompt,
24150
24748
  configuredProviders,
@@ -24171,6 +24769,7 @@ export {
24171
24769
  discoverGeminiModels,
24172
24770
  drainNext,
24173
24771
  effectiveAllowlist,
24772
+ effectiveApprovalMode,
24174
24773
  emptyWarnings,
24175
24774
  enabledRunners,
24176
24775
  ensureDir,
@@ -24253,6 +24852,8 @@ export {
24253
24852
  outputLines,
24254
24853
  outputPreview,
24255
24854
  ownerOnlyConfigFile,
24855
+ parseAllowlist,
24856
+ parseApprovalModeSetting,
24256
24857
  parseAutonomyLevel,
24257
24858
  parseMaxParallel,
24258
24859
  parsePartialPlan,
@@ -24310,6 +24911,7 @@ export {
24310
24911
  savePrdMarkdown,
24311
24912
  saveSession,
24312
24913
  saveState,
24914
+ scopeAllowed,
24313
24915
  serializePlan,
24314
24916
  serializeTask,
24315
24917
  serializeTaskStatus,
@@ -24347,6 +24949,7 @@ export {
24347
24949
  toTaskLogEvent,
24348
24950
  tokenSubprotocols,
24349
24951
  tokensMatch,
24952
+ toolAllowed,
24350
24953
  toolHeadline,
24351
24954
  trimToolOutput,
24352
24955
  truncateCheckpointSummary,