@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.js CHANGED
@@ -57,6 +57,7 @@ __export(index_exports, {
57
57
  DAEMON_SUBPROTOCOL: () => DAEMON_SUBPROTOCOL,
58
58
  DAEMON_TOKEN_SUBPROTOCOL_PREFIX: () => DAEMON_TOKEN_SUBPROTOCOL_PREFIX,
59
59
  DEFAULT_MAX_PARALLEL: () => DEFAULT_MAX_PARALLEL,
60
+ DEFAULT_PLANNER_ALLOWLIST: () => DEFAULT_PLANNER_ALLOWLIST,
60
61
  DEFAULT_RUNNERS: () => DEFAULT_RUNNERS,
61
62
  DENY_ALL: () => DENY_ALL,
62
63
  EMPTY_CONVERSATION: () => EMPTY_CONVERSATION,
@@ -193,6 +194,7 @@ __export(index_exports, {
193
194
  coerceAssignments: () => coerceAssignments,
194
195
  collectDirectDependencyOutputs: () => collectDirectDependencyOutputs,
195
196
  collectProviderCredentials: () => collectProviderCredentials,
197
+ commandAllowedBy: () => commandAllowedBy,
196
198
  composeAugmentedPrompt: () => composeAugmentedPrompt,
197
199
  composeContinuationPrompt: () => composeContinuationPrompt,
198
200
  configuredProviders: () => configuredProviders,
@@ -219,6 +221,7 @@ __export(index_exports, {
219
221
  discoverGeminiModels: () => discoverGeminiModels,
220
222
  drainNext: () => drainNext,
221
223
  effectiveAllowlist: () => effectiveAllowlist,
224
+ effectiveApprovalMode: () => effectiveApprovalMode,
222
225
  emptyWarnings: () => emptyWarnings,
223
226
  enabledRunners: () => enabledRunners,
224
227
  ensureDir: () => ensureDir,
@@ -301,6 +304,8 @@ __export(index_exports, {
301
304
  outputLines: () => outputLines,
302
305
  outputPreview: () => outputPreview,
303
306
  ownerOnlyConfigFile: () => ownerOnlyConfigFile,
307
+ parseAllowlist: () => parseAllowlist,
308
+ parseApprovalModeSetting: () => parseApprovalModeSetting,
304
309
  parseAutonomyLevel: () => parseAutonomyLevel,
305
310
  parseMaxParallel: () => parseMaxParallel,
306
311
  parsePartialPlan: () => parsePartialPlan,
@@ -358,6 +363,7 @@ __export(index_exports, {
358
363
  savePrdMarkdown: () => savePrdMarkdown,
359
364
  saveSession: () => saveSession,
360
365
  saveState: () => saveState,
366
+ scopeAllowed: () => scopeAllowed,
361
367
  serializePlan: () => serializePlan,
362
368
  serializeTask: () => serializeTask,
363
369
  serializeTaskStatus: () => serializeTaskStatus,
@@ -395,6 +401,7 @@ __export(index_exports, {
395
401
  toTaskLogEvent: () => toTaskLogEvent,
396
402
  tokenSubprotocols: () => tokenSubprotocols,
397
403
  tokensMatch: () => tokensMatch,
404
+ toolAllowed: () => toolAllowed,
398
405
  toolHeadline: () => toolHeadline,
399
406
  trimToolOutput: () => trimToolOutput,
400
407
  truncateCheckpointSummary: () => truncateCheckpointSummary,
@@ -1149,7 +1156,7 @@ function ansiCQuoteEnd(s, start) {
1149
1156
  function backtickBody(body, dialect) {
1150
1157
  return dialect.escape === "\\" ? body.replace(/\\([$`\\])/g, "$1") : body;
1151
1158
  }
1152
- function skipHereDocuments(s, start, docs, nested, dialect) {
1159
+ function skipHereDocuments(s, start, docs, nested2, dialect) {
1153
1160
  let pos = start;
1154
1161
  for (const doc of docs) {
1155
1162
  const bodyStart = pos;
@@ -1176,19 +1183,19 @@ function skipHereDocuments(s, start, docs, nested, dialect) {
1176
1183
  if (body[i] === "$" && body[i + 1] === "(") {
1177
1184
  const close = matchParen(body, i + 1, dialect);
1178
1185
  if (close < 0) return close;
1179
- nested.push(body.slice(i + 2, close));
1186
+ nested2.push(body.slice(i + 2, close));
1180
1187
  i = close;
1181
1188
  } else if (body[i] === "`") {
1182
1189
  const close = matchBacktick(body, i, dialect);
1183
1190
  if (close < 0) return -1;
1184
- nested.push(backtickBody(body.slice(i + 1, close), dialect));
1191
+ nested2.push(backtickBody(body.slice(i + 1, close), dialect));
1185
1192
  i = close;
1186
1193
  }
1187
1194
  }
1188
1195
  }
1189
1196
  return pos;
1190
1197
  }
1191
- function lex(command, nested, dialect) {
1198
+ function lex(command, nested2, dialect) {
1192
1199
  const segments = [];
1193
1200
  let unsafeRedirect;
1194
1201
  let processSubstitution = false;
@@ -1202,6 +1209,7 @@ function lex(command, nested, dialect) {
1202
1209
  let bareSegment = false;
1203
1210
  let tokens = [];
1204
1211
  let expandable = false;
1212
+ let globbed = false;
1205
1213
  let current = "";
1206
1214
  let started = false;
1207
1215
  let piped = false;
@@ -1265,7 +1273,7 @@ function lex(command, nested, dialect) {
1265
1273
  followable = false;
1266
1274
  bareSegment = true;
1267
1275
  }
1268
- segments.push({ ...seg, expandable, stdinRedirected, inputs, joinedBy });
1276
+ segments.push({ ...seg, expandable, globbed, stdinRedirected, inputs, joinedBy });
1269
1277
  } else if (!last || segments.length > 0 || joinedBy !== void 0) {
1270
1278
  followable = false;
1271
1279
  bareSegment = true;
@@ -1273,6 +1281,7 @@ function lex(command, nested, dialect) {
1273
1281
  tokens = [];
1274
1282
  inputs = [];
1275
1283
  expandable = false;
1284
+ globbed = false;
1276
1285
  stdinRedirected = false;
1277
1286
  piped = nextPiped;
1278
1287
  joinedBy = next;
@@ -1311,7 +1320,7 @@ function lex(command, nested, dialect) {
1311
1320
  unbalanced = true;
1312
1321
  break;
1313
1322
  }
1314
- nested.push(command.slice(i + 2, close));
1323
+ nested2.push(command.slice(i + 2, close));
1315
1324
  current += command.slice(i, close + 1);
1316
1325
  started = true;
1317
1326
  expandable = true;
@@ -1324,7 +1333,7 @@ function lex(command, nested, dialect) {
1324
1333
  unbalanced = true;
1325
1334
  break;
1326
1335
  }
1327
- nested.push(backtickBody(command.slice(i + 1, close), dialect));
1336
+ nested2.push(backtickBody(command.slice(i + 1, close), dialect));
1328
1337
  current += command.slice(i, close + 1);
1329
1338
  started = true;
1330
1339
  expandable = true;
@@ -1455,7 +1464,7 @@ function lex(command, nested, dialect) {
1455
1464
  endSegment(false, c === "&" && command[i + 1] === "&" ? "and" : "other");
1456
1465
  if (c === "\n") inComment = false;
1457
1466
  if (c === "\n" && hereDocs.length > 0) {
1458
- const end = skipHereDocuments(command, i + 1, hereDocs.splice(0), nested, dialect);
1467
+ const end = skipHereDocuments(command, i + 1, hereDocs.splice(0), nested2, dialect);
1459
1468
  if (end === UNREADABLE) {
1460
1469
  unreadable = true;
1461
1470
  break;
@@ -1483,6 +1492,7 @@ function lex(command, nested, dialect) {
1483
1492
  else if (c === "{") braceOpen = true;
1484
1493
  else if (braceOpen && (c === "," || c === "." && command[i + 1] === ".")) braceList = true;
1485
1494
  else if (braceOpen && braceList && c === "}") expandable = true;
1495
+ else if (c === "*" || c === "?" || c === "[") globbed = true;
1486
1496
  current += c;
1487
1497
  started = true;
1488
1498
  i++;
@@ -1518,8 +1528,10 @@ function toSegment(tokens, piped, dialect) {
1518
1528
  piped,
1519
1529
  stdinRedirected: false,
1520
1530
  expandable: false,
1531
+ globbed: false,
1521
1532
  inputs: [],
1522
1533
  ...first !== void 0 && isComputedWord(first, dialect) ? { computedBinary: first } : {},
1534
+ ...first !== void 0 && /[/\\]/.test(first) ? { binaryPath: first } : {},
1523
1535
  ...ambiguousCmdName !== void 0 ? { ambiguousCmdName } : {}
1524
1536
  };
1525
1537
  }
@@ -1808,7 +1820,7 @@ function unwrapXargs(seg, dialect) {
1808
1820
  if (replace && tokens[0].includes(replace)) {
1809
1821
  return { reason: `"xargs" substitutes its input into "${tokens[0]}", so the input decides which program runs. Name the program directly.` };
1810
1822
  }
1811
- return { seg: { ...toSegment(tokens, false, dialect), expandable: seg.expandable } };
1823
+ return { seg: { ...toSegment(tokens, false, dialect), expandable: seg.expandable, globbed: seg.globbed } };
1812
1824
  }
1813
1825
  function unwrap(seg, dialect) {
1814
1826
  const wrappers = [];
@@ -1839,7 +1851,7 @@ function unwrap(seg, dialect) {
1839
1851
  wrappers.push(current.binary);
1840
1852
  const inner = toSegment(scan.tokens, current.piped, dialect);
1841
1853
  assignments.push(...inner.assignments);
1842
- current = { ...inner, assignments: [], expandable: current.expandable, stdinRedirected: current.stdinRedirected };
1854
+ current = { ...inner, assignments: [], expandable: current.expandable, globbed: current.globbed, stdinRedirected: current.stdinRedirected };
1843
1855
  }
1844
1856
  return { seg: { ...current, assignments }, wrappers, runner };
1845
1857
  }
@@ -3690,6 +3702,340 @@ function awkProgramHazard(program) {
3690
3702
  return void 0;
3691
3703
  }
3692
3704
 
3705
+ // src/services/plannerAllowlist.ts
3706
+ 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;
3707
+ function globRegExp(glob, flags = "") {
3708
+ const escaped = glob.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*");
3709
+ return new RegExp(`^${escaped}$`, flags);
3710
+ }
3711
+ function ruleWord(pattern) {
3712
+ if (!pattern.includes("*")) return { matches: (word) => word === pattern, wildcard: false };
3713
+ const re = globRegExp(pattern);
3714
+ return { matches: (word) => re.test(word), wildcard: true };
3715
+ }
3716
+ function exclusionWord(pattern) {
3717
+ const re = /^-[A-Za-z]$/.test(pattern) ? new RegExp(`^-[A-Za-z]*${pattern[1]}`) : globRegExp(pattern, "i");
3718
+ return { matches: (word) => re.test(word), wildcard: pattern.includes("*") };
3719
+ }
3720
+ function isScopePattern(entry) {
3721
+ return /^(\/|~|https?:\/\/|mcp__|[A-Za-z]:[\\/])/.test(entry);
3722
+ }
3723
+ function parseAllowlist(entries) {
3724
+ const list = { commands: [], commandExclusions: [], tools: [], toolExclusions: [], scopes: [], scopeExclusions: [] };
3725
+ for (const raw of entries) {
3726
+ const exclude = raw.trim().startsWith("!");
3727
+ const entry = raw.trim().replace(/^!\s*/, "");
3728
+ if (!entry) continue;
3729
+ if (entry.startsWith("mcp:")) {
3730
+ (exclude ? list.toolExclusions : list.tools).push(globRegExp(entry.slice(4).trim(), "i"));
3731
+ } else if (isScopePattern(entry)) {
3732
+ (exclude ? list.scopeExclusions : list.scopes).push(globRegExp(entry));
3733
+ } else {
3734
+ const [binary, ...words] = entry.split(/\s+/);
3735
+ (exclude ? list.commandExclusions : list.commands).push({ text: raw.trim(), binary, words: words.map(exclude ? exclusionWord : ruleWord) });
3736
+ }
3737
+ }
3738
+ return list;
3739
+ }
3740
+ function leadingWords(args) {
3741
+ const lead = [];
3742
+ for (const arg of args) {
3743
+ if (arg.startsWith("-")) break;
3744
+ lead.push(arg);
3745
+ }
3746
+ return lead;
3747
+ }
3748
+ function ruleCovers(rule, command) {
3749
+ if (rule.binary !== command.binary) return false;
3750
+ const lead = leadingWords(command.args);
3751
+ if (lead.length < rule.words.length || rule.words.length < (command.scopeWords ?? 0)) return false;
3752
+ return rule.words.every((word, i) => word.matches(lead[i]) && !(word.wildcard && MUTATING_VERB.test(lead[i])));
3753
+ }
3754
+ function exclusionCovers(rule, command) {
3755
+ if (rule.binary !== "*" && rule.binary !== command.binary) return false;
3756
+ let next = 0;
3757
+ for (const arg of command.args) {
3758
+ if (next < rule.words.length && rule.words[next].matches(arg)) next += 1;
3759
+ }
3760
+ return next === rule.words.length;
3761
+ }
3762
+ function commandAllowedBy(list, command) {
3763
+ if (list.commandExclusions.some((rule) => exclusionCovers(rule, command))) return void 0;
3764
+ return list.commands.find((rule) => ruleCovers(rule, command))?.text;
3765
+ }
3766
+ function toolAllowed(list, tool2) {
3767
+ if (list.toolExclusions.some((re) => re.test(tool2))) return false;
3768
+ return list.tools.some((re) => re.test(tool2));
3769
+ }
3770
+ function scopeAllowed(list, scope) {
3771
+ if (list.scopeExclusions.some((re) => re.test(scope))) return false;
3772
+ return list.scopes.some((re) => re.test(scope)) || list.commands.some((rule) => rule.text === scope);
3773
+ }
3774
+ function withArgument(binary, verbs) {
3775
+ return verbs.flatMap((verb) => [`${binary} ${verb}`, `${binary} ${verb} *`]);
3776
+ }
3777
+ function nested(binary, verbs, depth) {
3778
+ return verbs.flatMap((verb) => Array.from({ length: depth }, (_, i) => [binary, ...Array(i).fill("*"), verb].join(" ")));
3779
+ }
3780
+ var KUBE_VERB_READS = ["get", "describe", "logs", "top", "explain", "events"];
3781
+ var KUBE_READS = [
3782
+ "api-resources",
3783
+ "api-versions",
3784
+ "version",
3785
+ "cluster-info",
3786
+ "auth can-i",
3787
+ "auth whoami",
3788
+ "config get-contexts",
3789
+ "config current-context",
3790
+ "config get-clusters",
3791
+ "rollout status",
3792
+ "rollout history"
3793
+ ];
3794
+ var CONTAINER_VERB_READS = ["ps", "images", "inspect", "logs", "history", "top", "port"];
3795
+ var CONTAINER_READS = [
3796
+ "version",
3797
+ "info",
3798
+ "image ls",
3799
+ "image list",
3800
+ "image inspect",
3801
+ "image history",
3802
+ "container ls",
3803
+ "container list",
3804
+ "container inspect",
3805
+ "container logs",
3806
+ "network ls",
3807
+ "network inspect",
3808
+ "volume ls",
3809
+ "volume inspect",
3810
+ "context ls",
3811
+ "context list"
3812
+ ];
3813
+ var DEFAULT_PLANNER_ALLOWLIST = [
3814
+ // GitHub, GitLab, Jira
3815
+ ...[
3816
+ "issue list",
3817
+ "issue view",
3818
+ "issue status",
3819
+ "pr list",
3820
+ "pr view",
3821
+ "pr diff",
3822
+ "pr checks",
3823
+ "pr status",
3824
+ "run list",
3825
+ "run view",
3826
+ "workflow list",
3827
+ "workflow view",
3828
+ "repo view",
3829
+ "repo list",
3830
+ "search",
3831
+ "label list",
3832
+ "search *",
3833
+ "status",
3834
+ "cache list",
3835
+ "project list",
3836
+ "project view",
3837
+ "project item-list",
3838
+ "gist list",
3839
+ "gist view",
3840
+ "variable list",
3841
+ "ruleset list",
3842
+ "ruleset view",
3843
+ "org list"
3844
+ ].map((c) => `gh ${c}`),
3845
+ ...[
3846
+ "issue list",
3847
+ "issue view",
3848
+ "mr list",
3849
+ "mr view",
3850
+ "mr diff",
3851
+ "ci list",
3852
+ "ci status",
3853
+ "repo view",
3854
+ "release list",
3855
+ "release view"
3856
+ ].map((c) => `glab ${c}`),
3857
+ ...["issue list", "issue view", "sprint list", "epic list", "project list"].map((c) => `jira ${c}`),
3858
+ "!gh -w",
3859
+ "!glab -w",
3860
+ // Azure: `az` takes no positional arguments, so a group path is always groups then verb.
3861
+ ...nested("az", ["list", "show"], 5),
3862
+ "az version",
3863
+ "az lock list",
3864
+ "az tag list",
3865
+ "az monitor log-analytics query",
3866
+ "az monitor app-insights query",
3867
+ "!az *keys*",
3868
+ "!az *secret*",
3869
+ "!az *credential*",
3870
+ "!az *password*",
3871
+ "!az *connection-string*",
3872
+ "!az *appsettings*",
3873
+ "!az *token*",
3874
+ // Google Cloud. `run` is spelled out: a wildcard never stands for it.
3875
+ ...nested("gcloud", ["list", "describe", "get-iam-policy"], 5),
3876
+ "gcloud run * list",
3877
+ "gcloud run * * list",
3878
+ "gcloud run * describe",
3879
+ "gcloud run * * describe",
3880
+ "gcloud config list",
3881
+ "gcloud config get",
3882
+ "gcloud config get-value",
3883
+ "gcloud auth list",
3884
+ "gcloud logging read",
3885
+ "gcloud info",
3886
+ "gcloud version",
3887
+ ...["ls", "du"].map((c) => `gsutil ${c}`),
3888
+ ...["ls", "show", "head"].map((c) => `bq ${c}`),
3889
+ "!gcloud *secret*",
3890
+ "!gcloud *token*",
3891
+ // AWS: always `aws <service> <operation>`.
3892
+ "aws * describe-*",
3893
+ "aws * list-*",
3894
+ "aws s3 ls",
3895
+ "aws sts get-caller-identity",
3896
+ "aws configure list",
3897
+ "aws logs filter-log-events",
3898
+ "aws logs get-log-events",
3899
+ "aws cloudwatch get-metric-data",
3900
+ "aws cloudwatch get-metric-statistics",
3901
+ "aws ce get-cost-and-usage",
3902
+ "aws iam get-user",
3903
+ "aws iam get-role",
3904
+ "!aws secretsmanager",
3905
+ "!aws *secret*",
3906
+ // Other clouds and platforms
3907
+ ...nested("doctl", ["list", "get"], 3),
3908
+ "doctl account get",
3909
+ "!doctl -u",
3910
+ ...nested("hcloud", ["list", "describe"], 2),
3911
+ ...nested("linode-cli", ["list", "view"], 2),
3912
+ ...nested("oci", ["list"], 4),
3913
+ ...[
3914
+ "status",
3915
+ "releases",
3916
+ "apps list",
3917
+ "machine list",
3918
+ "machines list",
3919
+ "secrets list",
3920
+ "regions list",
3921
+ "ips list",
3922
+ "volumes list"
3923
+ ].flatMap((c) => [`fly ${c}`, `flyctl ${c}`]),
3924
+ ...["apps", "apps:info", "ps", "logs", "releases", "addons", "domains", "pipelines"].map((c) => `heroku ${c}`),
3925
+ ...["ls", "list", "inspect", "logs", "env ls", "domains ls", "project ls", "whoami"].map((c) => `vercel ${c}`),
3926
+ "netlify status",
3927
+ "netlify sites:list",
3928
+ "firebase projects:list",
3929
+ "firebase apps:list",
3930
+ "firebase hosting:channel:list",
3931
+ "wrangler whoami",
3932
+ "wrangler deployments list",
3933
+ "wrangler d1 list",
3934
+ "wrangler kv namespace list",
3935
+ "supabase projects list",
3936
+ "supabase functions list",
3937
+ "railway status",
3938
+ // Kubernetes and friends. A kubeconfig the command names can run a credential plugin; `--raw` reads any API path.
3939
+ ...withArgument("kubectl", KUBE_VERB_READS),
3940
+ ...KUBE_READS.map((c) => `kubectl ${c}`),
3941
+ ...[...KUBE_VERB_READS, ...KUBE_READS, "status", "whoami", "projects"].map((c) => `oc ${c}`),
3942
+ ...["kubectl", "oc"].flatMap((bin) => ["*secret*", "-s", "dump", "--raw*", "-k", "--kustomize*"].map((w) => `!${bin} ${w}`)),
3943
+ "!oc whoami -t",
3944
+ "!oc *show-token*",
3945
+ ...withArgument("helm", ["list", "ls", "status", "history", "search"]),
3946
+ "helm version",
3947
+ "!helm --kube*",
3948
+ "eksctl get",
3949
+ "kind get",
3950
+ "k3d cluster list",
3951
+ "minikube status",
3952
+ "minikube profile list",
3953
+ "!kind *kubeconfig*",
3954
+ "argocd app list",
3955
+ "argocd app get",
3956
+ "argocd app history",
3957
+ "istioctl analyze",
3958
+ "istioctl proxy-status",
3959
+ "istioctl version",
3960
+ "!istioctl -c",
3961
+ "nomad status",
3962
+ "nomad job status",
3963
+ "consul members",
3964
+ "consul catalog services",
3965
+ "vault status",
3966
+ // Containers. Not `compose`, which reads the repository's compose file, remote includes and all.
3967
+ ...["docker", "podman"].flatMap((bin) => [...withArgument(bin, CONTAINER_VERB_READS), ...CONTAINER_READS.map((c) => `${bin} ${c}`), `!${bin} -H`]),
3968
+ // Infrastructure as code: only what reads no backend, whose address the repository's config sets.
3969
+ ...["version", "workspace show"].flatMap((c) => [`terraform ${c}`, `tofu ${c}`]),
3970
+ // Package metadata from what is installed: npm reads `node_modules`, pip its own environment.
3971
+ ...withArgument("npm", ["ls", "list", "why", "explain"]),
3972
+ ...["pip", "pip3"].flatMap((bin) => [...withArgument(bin, ["show"]), `${bin} list`, `${bin} freeze`, `!${bin} -i`, `!${bin} -f`]),
3973
+ // A module query (`@latest`) fetches from whatever host the module path names.
3974
+ ...withArgument("go", ["list", "env"]),
3975
+ "go version",
3976
+ "go mod graph",
3977
+ "go mod why",
3978
+ "!go -w",
3979
+ "!go -u",
3980
+ "!go -mod*",
3981
+ "!go *-toolexec*",
3982
+ "!go *-exec*",
3983
+ "!go *-compiled*",
3984
+ "!go *-export*",
3985
+ "!go *@*",
3986
+ // The local machine
3987
+ "ps",
3988
+ "lsof",
3989
+ "uptime",
3990
+ "free",
3991
+ "nproc",
3992
+ "hostname",
3993
+ "id",
3994
+ "journalctl",
3995
+ "!journalctl --vacuum*",
3996
+ "!journalctl --rotate",
3997
+ "!journalctl --flush",
3998
+ "!journalctl --sync",
3999
+ "!journalctl --relinquish*",
4000
+ "!journalctl --smart-relinquish*",
4001
+ "!journalctl --setup-keys",
4002
+ "!journalctl --update-catalog",
4003
+ "!hostname *",
4004
+ // Anything that would write a file, open a browser, or send the user's credentials to an address the command names.
4005
+ ...[
4006
+ "--output-file*",
4007
+ "--out-file*",
4008
+ "--outfile*",
4009
+ "--output-document*",
4010
+ "--output-directory*",
4011
+ "--log-file*",
4012
+ "--logfile*",
4013
+ "--download*",
4014
+ "--show-secrets*",
4015
+ "--with-decryption*",
4016
+ "--reveal*",
4017
+ "--web",
4018
+ "--server*",
4019
+ "*-addr*",
4020
+ "--endpoint*",
4021
+ "--api-url*",
4022
+ "--url*",
4023
+ "--host*",
4024
+ "--registry*",
4025
+ "--index-url*",
4026
+ "--extra-index-url*",
4027
+ "--find-links*",
4028
+ "--proxy*",
4029
+ "--kubeconfig*",
4030
+ "*apiserver*",
4031
+ "*http://*",
4032
+ "*https://*"
4033
+ ].map((w) => `!* ${w}`),
4034
+ // MCP tools that read: the verb leads the tool's own name.
4035
+ ...["get*", "list*", "find*", "search*", "read*", "view*", "describe*", "show*"].map((v) => `mcp:${v}`),
4036
+ ...["*secret*", "*token*", "*password*", "*credential*", "*key*", "*url*", "*fetch*", "*http*"].map((w) => `!mcp:${w}`)
4037
+ ];
4038
+
3693
4039
  // src/services/commandPolicy.ts
3694
4040
  var AUTO_COMMANDS = [
3695
4041
  "ls",
@@ -4123,18 +4469,20 @@ function patternArgs(seg) {
4123
4469
  }
4124
4470
  var SCOPE_LEAD_ARGS = 2;
4125
4471
  function scopeFor(seg) {
4126
- if (!MULTIPLEXERS.includes(seg.binary.toLowerCase())) return seg.binary;
4472
+ const name = seg.binaryPath ?? seg.binary;
4473
+ if (!MULTIPLEXERS.includes(seg.binary.toLowerCase())) return name;
4127
4474
  const lead = [];
4128
4475
  for (const arg of seg.args) {
4129
4476
  if (arg.startsWith("-")) break;
4130
4477
  lead.push(arg);
4131
4478
  if (lead.length === SCOPE_LEAD_ARGS) break;
4132
4479
  }
4133
- return [seg.binary, ...lead].join(" ");
4480
+ return [name, ...lead].join(" ");
4134
4481
  }
4135
4482
  function isAuto(seg, opts) {
4136
4483
  if (seg.binary === "cd") return !opts.cdpathSet && !seg.cdSteered && literalCdTarget(seg) !== void 0;
4137
4484
  if (!AUTO_COMMANDS.includes(seg.binary)) return false;
4485
+ if (seg.binaryPath !== void 0) return false;
4138
4486
  if (seg.expandable) return false;
4139
4487
  if (seg.binary !== "git") return true;
4140
4488
  const sub = subcommandOf(seg);
@@ -4321,7 +4669,18 @@ function classifyCommand(command, opts = {}) {
4321
4669
  return { tier: "ask", scope: trimmed };
4322
4670
  }
4323
4671
  const scope = [...new Set(nonAuto.map((u) => u.runner ? `${u.runner} ${scopeFor(u.seg)}` : scopeFor(u.seg)))].sort().join(" + ");
4324
- return { tier: "ask", scope };
4672
+ const allowedBy = opts.allow ? allowlistRule(nonAuto, opts.allow) : void 0;
4673
+ return { tier: "ask", scope, ...allowedBy ? { allowedBy } : {} };
4674
+ }
4675
+ function allowlistRule(parts, allow) {
4676
+ const rules = [];
4677
+ for (const { seg, runner } of parts) {
4678
+ if (runner || seg.expandable || seg.globbed || seg.binaryPath !== void 0 || seg.assignments.length > 0) return void 0;
4679
+ const rule = commandAllowedBy(allow, { binary: seg.binary, args: seg.args, scopeWords: scopeFor(seg).split(" ").length - 1 });
4680
+ if (!rule) return void 0;
4681
+ rules.push(rule);
4682
+ }
4683
+ return [...new Set(rules)].join(" + ");
4325
4684
  }
4326
4685
 
4327
4686
  // src/services/researchShell.ts
@@ -4637,7 +4996,8 @@ ${refs.output.trim()}`);
4637
4996
  * directory the next command names.
4638
4997
  */
4639
4998
  async bash(command, signal) {
4640
- const { tier, scope, reason } = classifyCommand(command, this.policyOptions());
4999
+ const allow = this.approval.allowlist?.();
5000
+ const { tier, scope, reason, allowedBy } = classifyCommand(command, { ...this.policyOptions(), ...allow ? { allow } : {} });
4641
5001
  if (tier === "refuse") {
4642
5002
  return { success: false, output: `Command refused: ${reason}`, truncated: false };
4643
5003
  }
@@ -4652,13 +5012,14 @@ ${refs.output.trim()}`);
4652
5012
  subject: asks ? command : outside[0].abs,
4653
5013
  scope: scopes[0],
4654
5014
  ...scopes.length > 1 ? { scopes } : {},
5015
+ ...asks && allowedBy ? { allowedBy } : {},
4655
5016
  detail: outside.length > 0 ? `Planner research wants to run "${command}", which touches ${touched}, outside the workspace (${root}).` : `Planner research wants to run: ${command}`
4656
5017
  });
4657
5018
  if (!granted) {
4658
5019
  return {
4659
5020
  success: false,
4660
5021
  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}
4661
- It is outside the auto-allowed read-only set (${scope}). Continue with the read-only research tools, or ask the user to approve it.`,
5022
+ 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.`,
4662
5023
  truncated: false
4663
5024
  };
4664
5025
  }
@@ -4886,14 +5247,19 @@ async function runTool(name, args, fs17, fetcher, signal) {
4886
5247
  }
4887
5248
 
4888
5249
  // src/services/ApprovalPolicy.ts
4889
- function scopeMatches(pattern, scope) {
4890
- if (pattern === scope) return true;
4891
- if (pattern.endsWith("*")) return scope.startsWith(pattern.slice(0, -1));
4892
- return false;
5250
+ function effectiveApprovalMode(setting, autonomous) {
5251
+ if (setting !== "auto") return setting;
5252
+ return autonomous ? "deny" : "ask";
5253
+ }
5254
+ function parseApprovalModeSetting(raw) {
5255
+ const value = (raw ?? "").trim().toLowerCase();
5256
+ if (value === "allowlist") return "deny";
5257
+ return value === "ask" || value === "allow" || value === "deny" ? value : "auto";
4893
5258
  }
4894
5259
  var ApprovalPolicy = class {
4895
- mode;
4896
- preApproved;
5260
+ modeOf;
5261
+ entriesOf;
5262
+ parsed = null;
4897
5263
  asker;
4898
5264
  onDecision;
4899
5265
  granted = /* @__PURE__ */ new Set();
@@ -4905,8 +5271,9 @@ var ApprovalPolicy = class {
4905
5271
  // generation it was started under and bails if reset has intervened.
4906
5272
  generation = 0;
4907
5273
  constructor(opts = {}) {
4908
- this.mode = opts.mode ?? "ask";
4909
- this.preApproved = opts.preApproved ?? [];
5274
+ const { mode = "ask", preApproved = [] } = opts;
5275
+ this.modeOf = typeof mode === "function" ? mode : () => mode;
5276
+ this.entriesOf = typeof preApproved === "function" ? preApproved : () => preApproved;
4910
5277
  this.asker = opts.ask;
4911
5278
  this.onDecision = opts.onDecision;
4912
5279
  }
@@ -4917,19 +5284,19 @@ var ApprovalPolicy = class {
4917
5284
  };
4918
5285
  const scopes = approvalScopes(req);
4919
5286
  const key = scopes.join("\n");
4920
- if (scopes.some((s) => this.refused.has(s)) || this.refused.has(key)) return decide(false, "remembered");
4921
- const open = scopes.filter((s) => !this.granted.has(s) && !this.preApproved.some((p) => scopeMatches(p, s)));
5287
+ const list = this.allowlist();
5288
+ 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));
5289
+ const open = scopes.filter((s, i) => !this.granted.has(s) && !preApproved(s, i));
4922
5290
  if (open.length === 0) {
4923
5291
  return decide(true, scopes.every((s) => this.granted.has(s)) ? "remembered" : "pre-approved");
4924
5292
  }
4925
- if (this.mode === "allow") {
5293
+ if (scopes.some((s) => this.refused.has(s)) || this.refused.has(key)) return decide(false, "remembered");
5294
+ const mode = this.modeOf();
5295
+ if (mode === "allow") {
4926
5296
  open.forEach((s) => this.granted.add(s));
4927
5297
  return decide(true, "mode");
4928
5298
  }
4929
- if (this.mode === "deny") {
4930
- this.refused.add(key);
4931
- return decide(false, "mode");
4932
- }
5299
+ if (mode === "deny") return decide(false, "mode");
4933
5300
  if (!this.asker) return decide(false, "no-channel");
4934
5301
  const openKey = open.join("\n");
4935
5302
  const existing = this.inFlight.get(openKey);
@@ -4947,6 +5314,13 @@ var ApprovalPolicy = class {
4947
5314
  }
4948
5315
  return decide(answer2, "asked");
4949
5316
  }
5317
+ /** The standing approvals, parsed from the entries in force now. */
5318
+ allowlist() {
5319
+ const entries = this.entriesOf();
5320
+ const key = entries.join("\n");
5321
+ if (this.parsed?.key !== key) this.parsed = { key, list: parseAllowlist(entries) };
5322
+ return this.parsed.list;
5323
+ }
4950
5324
  /** Scopes the user has granted this session — for display and for persistence. */
4951
5325
  grantedScopes() {
4952
5326
  return [...this.granted];
@@ -5044,14 +5418,17 @@ var KNOWN_TOOLS = {
5044
5418
  local_shell: "bash",
5045
5419
  // OpenCode
5046
5420
  list: "list_dir",
5047
- fetch: "fetch"
5421
+ fetch: "fetch",
5422
+ // Ordewell's own research shell (ADR-0026), under every runner's name for it
5423
+ runcommand: "bash"
5048
5424
  };
5425
+ var ORDEWELL_TOOL_PREFIX = /^(mcp__ordewell__|ordewell_)/i;
5049
5426
  var FILE_EDIT_TOOLS = /* @__PURE__ */ new Set(["edit", "write", "multiedit", "notebookedit", "applypatch", "patch", "add", "update", "delete", "filechange"]);
5050
5427
  function isFileEditTool(name) {
5051
5428
  return FILE_EDIT_TOOLS.has(name.trim().toLowerCase().replace(/[_-]/g, ""));
5052
5429
  }
5053
5430
  function mapAgentTool(name) {
5054
- const normalized = name.trim().toLowerCase().replace(/[_-]/g, "");
5431
+ const normalized = name.trim().replace(ORDEWELL_TOOL_PREFIX, "").toLowerCase().replace(/[_-]/g, "");
5055
5432
  const direct = KNOWN_TOOLS[normalized] ?? KNOWN_TOOLS[name.trim().toLowerCase()];
5056
5433
  if (direct) {
5057
5434
  return { tool: direct, toolLabel: name };
@@ -6700,8 +7077,10 @@ var BaseConfig = class {
6700
7077
  return nonNegativeInt(process.env.ORDEWELL_CONFLICT_REPAIR_ATTEMPTS, 2);
6701
7078
  }
6702
7079
  get approvalMode() {
6703
- const raw = (process.env.ORDEWELL_APPROVAL_MODE || "").trim().toLowerCase();
6704
- return raw === "allow" || raw === "deny" ? raw : "ask";
7080
+ return parseApprovalModeSetting(process.env.ORDEWELL_APPROVAL_MODE);
7081
+ }
7082
+ get approvalDefaults() {
7083
+ return process.env.ORDEWELL_APPROVAL_DEFAULTS !== "false" && process.env.ORDEWELL_APPROVAL_DEFAULTS !== "0";
6705
7084
  }
6706
7085
  get approvalPreApproved() {
6707
7086
  return (process.env.ORDEWELL_APPROVAL_ALLOW || "").split(/[,\n]/).map((s) => s.trim()).filter(Boolean);
@@ -7544,7 +7923,7 @@ var GitWorktreeIsolation = class {
7544
7923
  return this.admin(workspaceRoot, async () => {
7545
7924
  const scan = await this.scanGroup(workspaceRoot);
7546
7925
  const paths = "paths" in scan ? scan.paths : [SELF_REPO];
7547
- const nested = "paths" in scan ? scan.nested : [];
7926
+ const nested2 = "paths" in scan ? scan.nested : [];
7548
7927
  const run = { id: this.mintRunId(), workspaceRoot, repos: [], shared: [], sharedRepos: [], tasks: {} };
7549
7928
  for (const repoPath of paths) {
7550
7929
  const repo = await this.startRepo(run, repoPath);
@@ -7552,7 +7931,7 @@ var GitWorktreeIsolation = class {
7552
7931
  else run.sharedRepos.push(repoPath);
7553
7932
  }
7554
7933
  if (run.repos.length === 0) throw new Error(`No repository could be isolated: ${run.sharedRepos.join(", ")}`);
7555
- run.shared = paths.includes(SELF_REPO) ? nested : this.sharedPaths(workspaceRoot, run.repos.map((r) => r.path));
7934
+ run.shared = paths.includes(SELF_REPO) ? nested2 : this.sharedPaths(workspaceRoot, run.repos.map((r) => r.path));
7556
7935
  return run;
7557
7936
  });
7558
7937
  }
@@ -9093,6 +9472,7 @@ function researchPhaseBlock(harnessMode) {
9093
9472
  const toolSpecific = harnessMode ? [
9094
9473
  "- Explore the repository with your own tools until you understand its architecture",
9095
9474
  "- You are planning, not implementing: do NOT edit, create, or delete any file, and do not run commands that change the workspace",
9475
+ "- 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",
9096
9476
  "- 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."
9097
9477
  ] : [
9098
9478
  "- Use list_dir with depth=2-3 for a tree overview",
@@ -12559,6 +12939,9 @@ var listRunnersInput = import_v4.z.object({});
12559
12939
  var listModelsInput = import_v4.z.object({
12560
12940
  runner: import_v4.z.string().min(1).describe("A runner id from list_runners.")
12561
12941
  });
12942
+ var runCommandInput = import_v4.z.object({
12943
+ command: import_v4.z.string().min(1).describe("One shell command line, run from the workspace root.")
12944
+ });
12562
12945
  var loadSkillInput = import_v4.z.object({
12563
12946
  name: import_v4.z.string().min(1).describe("The name of an Ordewell planner skill to load.")
12564
12947
  });
@@ -12698,6 +13081,13 @@ var PLANNER_TOOLS = [
12698
13081
  (h) => h.taskOutput?.bind(h),
12699
13082
  PLANNER_READ_ONLY
12700
13083
  ),
13084
+ tool(
13085
+ "run_command",
13086
+ "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.",
13087
+ runCommandInput,
13088
+ (h) => h.runCommand?.bind(h),
13089
+ PLANNER_READ_ONLY
13090
+ ),
12701
13091
  tool(
12702
13092
  "load_skill",
12703
13093
  "Load an Ordewell planner skill by name. Returns its instructions; only model-invocable planner skills can be loaded.",
@@ -15185,6 +15575,7 @@ var SETTINGS_ENV_REFUSED = [
15185
15575
  "ORDEWELL_AUTONOMOUS_MODE",
15186
15576
  "ORDEWELL_APPROVAL_MODE",
15187
15577
  "ORDEWELL_APPROVAL_ALLOW",
15578
+ "ORDEWELL_APPROVAL_DEFAULTS",
15188
15579
  // Where settings are persisted.
15189
15580
  "ORDEWELL_SETTINGS_PATH",
15190
15581
  // Runtime options and dynamic loaders.
@@ -15783,6 +16174,32 @@ function killTree(proc, deps = {}) {
15783
16174
  // src/services/harness/openCodeOrdewell.ts
15784
16175
  var NAMES = prefixedToolNames(`${ORDEWELL_MCP_SERVER_NAME}_`);
15785
16176
  var RULE = NAMES.toolName("*");
16177
+ var OPENCODE_PLANNER_PERMISSION = {
16178
+ "*": "ask",
16179
+ read: "allow",
16180
+ glob: "allow",
16181
+ grep: "allow",
16182
+ list: "allow",
16183
+ lsp: "allow",
16184
+ skill: "allow",
16185
+ todoread: "allow",
16186
+ websearch: "allow",
16187
+ codesearch: "allow",
16188
+ webfetch: "ask",
16189
+ external_directory: "ask",
16190
+ edit: "deny",
16191
+ write: "deny",
16192
+ patch: "deny",
16193
+ apply_patch: "deny",
16194
+ multiedit: "deny",
16195
+ bash: "deny",
16196
+ task: "deny",
16197
+ question: "deny",
16198
+ todowrite: "deny",
16199
+ shell: "deny",
16200
+ subagent: "deny",
16201
+ browser: "deny"
16202
+ };
15786
16203
  var OPENCODE_CONFIG_VARIABLE = "OPENCODE_CONFIG_CONTENT";
15787
16204
  function isRecord2(value) {
15788
16205
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -15820,7 +16237,7 @@ function withoutParentOrdewell(content) {
15820
16237
  const kept = withoutOrdewell(parsed);
15821
16238
  return JSON.stringify(kept) === JSON.stringify(parsed) ? content : JSON.stringify(kept);
15822
16239
  }
15823
- function mergeOrdewellConfig(existing, mcp) {
16240
+ function mergeOrdewellConfig(existing, mcp, role) {
15824
16241
  let base = {};
15825
16242
  if (existing?.trim()) {
15826
16243
  let parsed;
@@ -15832,10 +16249,14 @@ function mergeOrdewellConfig(existing, mcp) {
15832
16249
  if (!isRecord2(parsed)) return null;
15833
16250
  base = withoutOrdewell(parsed);
15834
16251
  }
16252
+ if (role === "planner") {
16253
+ delete base.permission;
16254
+ delete base.permissions;
16255
+ }
15835
16256
  const policy = typeof base.permission === "string" ? { "*": base.permission } : base.permission;
15836
16257
  const ours = {
15837
- mcp: { [mcp.name]: { type: "remote", url: mcp.url, headers: mcp.headers, enabled: true } },
15838
- permission: { [RULE]: "allow" }
16258
+ ...mcp ? { mcp: { [mcp.name]: { type: "remote", url: mcp.url, headers: mcp.headers, enabled: true } } } : {},
16259
+ permission: { ...role === "planner" ? OPENCODE_PLANNER_PERMISSION : {}, ...mcp ? { [RULE]: "allow" } : {} }
15839
16260
  };
15840
16261
  return JSON.stringify(deepMerge({ ...base, ...policy === void 0 ? {} : { permission: policy } }, ours));
15841
16262
  }
@@ -16280,7 +16701,24 @@ var DISALLOWED_TOOLS = [
16280
16701
  "EnterPlanMode",
16281
16702
  "ExitPlanMode"
16282
16703
  ];
16283
- var PLANNER_PERMISSION_MODE = "dontAsk";
16704
+ var PLANNER_PERMISSION_MODE = "default";
16705
+ var PATH_TOOLS = {
16706
+ Read: { field: "file_path", directory: false },
16707
+ Grep: { field: "path", directory: true },
16708
+ Glob: { field: "path", directory: true },
16709
+ LS: { field: "path", directory: true }
16710
+ };
16711
+ function plannerAsk(name, input, server) {
16712
+ if (name.startsWith("mcp__")) {
16713
+ const tool2 = name.split("__").slice(2).join("__");
16714
+ return { kind: "mcp", scope: name, tool: tool2 || name, ...server ? { server } : {} };
16715
+ }
16716
+ if (name === "WebFetch" && typeof input.url === "string") return { kind: "fetch", url: input.url };
16717
+ const pathTool = PATH_TOOLS[name];
16718
+ const target = pathTool ? input[pathTool.field] : void 0;
16719
+ if (pathTool && typeof target === "string") return { kind: "path", path: target, directory: pathTool.directory };
16720
+ return { kind: "other" };
16721
+ }
16284
16722
  var TASK_DISALLOWED_TOOLS = ["AskUserQuestion"];
16285
16723
  var PROTOCOL_ARGS = [
16286
16724
  "-p",
@@ -16390,6 +16828,8 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
16390
16828
  const args = [
16391
16829
  ...PROTOCOL_ARGS,
16392
16830
  // The read-only guarantee, enforced at spawn rather than by prompt.
16831
+ "--permission-prompt-tool",
16832
+ "stdio",
16393
16833
  "--permission-mode",
16394
16834
  PLANNER_PERMISSION_MODE,
16395
16835
  "--disallowedTools",
@@ -16576,32 +17016,20 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
16576
17016
  const subagentId = msg.parent_tool_use_id ?? void 0;
16577
17017
  switch (msg.type) {
16578
17018
  // The control channel: Claude asks whether a tool may run when its mode
16579
- // cannot decide alone. A read-only planner answers "deny", every time —
16580
- // and must answer, because an unacknowledged request stalls the turn.
16581
- // A task's request is left open for someone to answer (ADR-0018, A1).
17019
+ // cannot decide alone. The request is left open — a task's for someone
17020
+ // to answer (ADR-0018, A1), a planner's for its envelope (ADR-0026) —
17021
+ // and must be answered, because an unacknowledged request stalls the turn.
16582
17022
  case "control_request": {
16583
17023
  if (msg.request?.subtype !== "can_use_tool") return;
16584
17024
  const input = msg.request.input ?? {};
16585
17025
  const id = msg.request_id ?? "";
16586
- if (this.role === "planner") {
16587
- this.writeLine({
16588
- type: "control_response",
16589
- response: {
16590
- subtype: "success",
16591
- request_id: msg.request_id,
16592
- response: { behavior: "deny", message: "The Ordewell planner is read-only. Mutation belongs to the runners that execute the plan." }
16593
- }
16594
- });
16595
- emit({ type: "permission_request", id, name: msg.request.tool_name ?? "unknown", detail: JSON.stringify(input) });
16596
- return;
16597
- }
16598
17026
  const name = msg.request.tool_name ?? "unknown";
16599
17027
  if (this.givenOrdewell && CLAUDE_ORDEWELL.isOrdewellAsk(name)) {
16600
17028
  this.writeLine({ type: "control_response", response: { subtype: "success", request_id: id, response: { behavior: "allow", updatedInput: input } } });
16601
17029
  emit({ type: "permission_request", id, name, detail: JSON.stringify(input), input, decided: { decision: "allow" } });
16602
17030
  return;
16603
17031
  }
16604
- const suggestions = msg.request.permission_suggestions ?? [];
17032
+ const suggestions = this.role === "task" ? msg.request.permission_suggestions ?? [] : [];
16605
17033
  this.openPermissions.set(id, { input, suggestions });
16606
17034
  emit({
16607
17035
  type: "permission_request",
@@ -16610,7 +17038,8 @@ var ClaudeCodeAdapter = class extends StdioAgentAdapter {
16610
17038
  detail: JSON.stringify(input),
16611
17039
  input,
16612
17040
  suggestions,
16613
- ...msg.request.tool_use_id ? { toolUseId: msg.request.tool_use_id } : {}
17041
+ ...msg.request.tool_use_id ? { toolUseId: msg.request.tool_use_id } : {},
17042
+ ...this.role === "planner" ? { ask: plannerAsk(name, input, msg.request.mcp_server?.name) } : {}
16614
17043
  });
16615
17044
  return;
16616
17045
  }
@@ -16902,6 +17331,20 @@ var CODEX_ORDEWELL = {
16902
17331
  `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"))}.`,
16903
17332
  `Look for them before you finish; the task is reported complete through \`${TASK_COMPLETE_TOOL}\`.`
16904
17333
  ].join(" ");
17334
+ },
17335
+ /**
17336
+ * The same discovery note for a planner, which also needs to know that its
17337
+ * commands go through `run_command`: Codex's own shell is sandboxed without
17338
+ * network, so `gh` or a cloud CLI fails there, and a model that tried its
17339
+ * shell first concludes the command cannot run.
17340
+ */
17341
+ plannerInstructions() {
17342
+ const tools = ordewellToolNames("planner");
17343
+ return [
17344
+ `You have ${COUNT_WORDS[tools.length] ?? tools.length} tools from the \`${ORDEWELL_MCP_SERVER_NAME}\` MCP server: ${quoted2(tools)}.`,
17345
+ `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"))}.`,
17346
+ `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.`
17347
+ ].join(" ");
16905
17348
  }
16906
17349
  };
16907
17350
 
@@ -17089,6 +17532,8 @@ var CodexAdapter = class extends StdioAgentAdapter {
17089
17532
  interruptedTurnIds = /* @__PURE__ */ new Set();
17090
17533
  /** A task's approval requests still waiting for an answer, by the request id as a string. */
17091
17534
  openPermissions = /* @__PURE__ */ new Map();
17535
+ /** The tool each MCP server's latest call is for, which names the tool an approval asks about when its question does not. */
17536
+ mcpCallsInProgress = /* @__PURE__ */ new Map();
17092
17537
  /**
17093
17538
  * The files each in-flight `fileChange` item touches. Its approval request
17094
17539
  * names only the item, and a person deciding needs to see the files.
@@ -17196,14 +17641,18 @@ ${this.exitMessage()}`);
17196
17641
  return {
17197
17642
  ...common,
17198
17643
  sandbox: "read-only",
17199
- approvalPolicy: "never",
17644
+ // `on-request`, so a user's MCP tool reaches the planner's envelope as an approval
17645
+ // (ADR-0026); under `never` Codex fails the call without asking. Commands and file
17646
+ // changes it asks about are still declined: the sandbox holds, and commands go
17647
+ // through Ordewell's `run_command`.
17648
+ approvalPolicy: "on-request",
17200
17649
  ...threadConfig,
17201
17650
  // `developerInstructions` layers on top of Codex's own base prompt, the
17202
17651
  // way Claude Code's `--append-system-prompt` does. `baseInstructions`
17203
17652
  // replaces it — which takes Codex's description of its own tools with
17204
17653
  // it, and a planner that has forgotten it can read the workspace
17205
17654
  // answers from a web search instead.
17206
- developerInstructions: opts?.systemPrompt
17655
+ developerInstructions: [opts?.systemPrompt, opts?.mcp ? CODEX_ORDEWELL.plannerInstructions() : ""].filter(Boolean).join("\n\n") || void 0
17207
17656
  };
17208
17657
  }
17209
17658
  /**
@@ -17307,7 +17756,7 @@ ${this.exitMessage()}`);
17307
17756
  if (!open || !this.process) return false;
17308
17757
  this.openPermissions.delete(id);
17309
17758
  this.rpc.respond(open.requestId, TASK_APPROVALS[open.method].answer(decision.decision, open.params));
17310
- const note = decision.decision === "deny" ? decision.note?.trim() : void 0;
17759
+ const note = decision.decision === "deny" && this.role === "task" ? decision.note?.trim() : void 0;
17311
17760
  if (note && this.turn?.id) this.requestSteer(this.turn.id, note);
17312
17761
  return true;
17313
17762
  }
@@ -17593,10 +18042,12 @@ ${this.exitMessage()}`);
17593
18042
  */
17594
18043
  answerServerRequest(msg, emit) {
17595
18044
  const method = msg.method;
17596
- if (CODEX_ORDEWELL.isOrdewellAsk({ method, params: msg.params ?? {} })) {
18045
+ const params = msg.params ?? {};
18046
+ if (CODEX_ORDEWELL.isOrdewellAsk({ method, params })) {
17597
18047
  this.rpc.respond(msg.id, { action: "accept", content: {} });
17598
18048
  return;
17599
18049
  }
18050
+ if (this.holdMcpToolApproval(msg, method, params, emit)) return;
17600
18051
  const result = DECLINE_RESULTS[method];
17601
18052
  if (result) {
17602
18053
  this.rpc.respond(msg.id, result);
@@ -17608,9 +18059,30 @@ ${this.exitMessage()}`);
17608
18059
  type: "permission_request",
17609
18060
  id: String(msg.id ?? ""),
17610
18061
  name: method.split("/").pop() ?? method,
17611
- detail: JSON.stringify(msg.params ?? {})
18062
+ detail: JSON.stringify(msg.params ?? {}),
18063
+ decided: { decision: "deny" }
17612
18064
  });
17613
18065
  }
18066
+ /**
18067
+ * A planner's approval for a tool of the user's own MCP server, held for the
18068
+ * planner's envelope to answer (ADR-0026). Codex names the server; the tool
18069
+ * is in its question, or else the call it last started on that server.
18070
+ */
18071
+ holdMcpToolApproval(msg, method, params, emit) {
18072
+ if (method !== "mcpServer/elicitation/request") return false;
18073
+ const meta = params._meta;
18074
+ const server = params.serverName;
18075
+ if (meta?.codex_approval_kind !== "mcp_tool_call" || typeof server !== "string") return false;
18076
+ const asked = typeof params.message === "string" ? /run tool "([^"]+)"/.exec(params.message)?.[1] : void 0;
18077
+ const tool2 = asked ?? this.mcpCallsInProgress.get(server);
18078
+ if (!tool2) return false;
18079
+ const id = String(msg.id);
18080
+ const input = typeof meta.tool_params === "object" && meta.tool_params !== null ? meta.tool_params : {};
18081
+ const scope = `mcp__${server}__${tool2}`;
18082
+ this.openPermissions.set(id, { requestId: msg.id, method, params });
18083
+ emit({ type: "permission_request", id, name: scope, detail: JSON.stringify(input), input, ask: { kind: "mcp", scope, tool: tool2, server } });
18084
+ return true;
18085
+ }
17614
18086
  /** A tool item entering `inProgress` — announce the call so the timeline moves. */
17615
18087
  emitItemStart(item, emit, subagentId) {
17616
18088
  if (!item?.id) return;
@@ -17622,6 +18094,7 @@ ${this.exitMessage()}`);
17622
18094
  emit({ type: "tool_call", id: item.id, name: "shell", args: { command: item.command, cwd: item.cwd }, ...subagentId ? { subagentId } : {} });
17623
18095
  return;
17624
18096
  case "mcpToolCall":
18097
+ if (item.server && item.tool) this.mcpCallsInProgress.set(item.server, item.tool);
17625
18098
  emit({ type: "tool_call", id: item.id, name: shownToolName(item), args: item.arguments ?? {}, ...subagentId ? { subagentId } : {} });
17626
18099
  return;
17627
18100
  case "dynamicToolCall":
@@ -17794,6 +18267,55 @@ var import_crypto9 = require("crypto");
17794
18267
  // src/services/harness/openCodeTransport.ts
17795
18268
  var import_crypto8 = require("crypto");
17796
18269
  var STREAM_CONNECT_TIMEOUT_MS = 5e3;
18270
+ var OPENCODE_BUILTINS = /* @__PURE__ */ new Set([
18271
+ "bash",
18272
+ "edit",
18273
+ "write",
18274
+ "patch",
18275
+ "apply_patch",
18276
+ "multiedit",
18277
+ "read",
18278
+ "glob",
18279
+ "grep",
18280
+ "list",
18281
+ "lsp",
18282
+ "task",
18283
+ "question",
18284
+ "todowrite",
18285
+ "todoread",
18286
+ "skill",
18287
+ "websearch",
18288
+ "codesearch",
18289
+ "webfetch",
18290
+ "external_directory",
18291
+ "doom_loop",
18292
+ "invalid",
18293
+ "batch",
18294
+ "shell",
18295
+ "subagent",
18296
+ "browser",
18297
+ "execute",
18298
+ "search",
18299
+ "opencode_read_mcp_resource",
18300
+ "opencode_list_mcp_resources"
18301
+ ]);
18302
+ function openCodePlannerAsk(permission, details, servers) {
18303
+ const meta = details.metadata ?? {};
18304
+ const pattern = details.patterns?.[0];
18305
+ if (permission === "webfetch") {
18306
+ const url = typeof meta.url === "string" ? meta.url : pattern;
18307
+ return url ? { kind: "fetch", url } : { kind: "other" };
18308
+ }
18309
+ if (permission === "external_directory") {
18310
+ if (typeof meta.filepath === "string") return { kind: "path", path: meta.filepath, directory: false };
18311
+ const dir = typeof meta.parentDir === "string" ? meta.parentDir : pattern?.replace(/[\\/]\*$/, "");
18312
+ return dir ? { kind: "path", path: dir, directory: true } : { kind: "other" };
18313
+ }
18314
+ if (OPENCODE_BUILTINS.has(permission)) return { kind: "other" };
18315
+ const server = servers.filter((s) => permission.startsWith(`${s}_`)).sort((a, b) => b.length - a.length)[0];
18316
+ if (!server) return { kind: "other" };
18317
+ return { kind: "mcp", scope: permission, tool: permission.slice(server.length + 1), server };
18318
+ }
17797
18319
  function splitModelId(id) {
17798
18320
  const slash = id.indexOf("/");
17799
18321
  if (slash <= 0 || slash === id.length - 1) return null;
@@ -17887,7 +18409,6 @@ var PendingSteers = class {
17887
18409
  }
17888
18410
  };
17889
18411
  var ALLOW = { decision: "allow" };
17890
- var DENY = { decision: "deny" };
17891
18412
  var OpenCodePermissions = class {
17892
18413
  constructor(reply2, ordewell) {
17893
18414
  this.reply = reply2;
@@ -17898,23 +18419,8 @@ var OpenCodePermissions = class {
17898
18419
  /** Requests waiting for an answer, by request id, with the session that asked. */
17899
18420
  open = /* @__PURE__ */ new Map();
17900
18421
  /**
17901
- * A planner's request (T1): refused, which is the same "absent answer is a
17902
- * denial" invariant ADR-0008 states for Ordewell's own tools. The refusal is
17903
- * announced so the timeline shows the planner reaching for something it may
17904
- * not have. The planner's own submission path, which the allow rule should
17905
- * already have settled, is the one request it may not be refused.
17906
- */
17907
- refuse(request, sessionId, seen, onEvent) {
17908
- if (!firstSight(request.id, seen)) return;
17909
- if (this.isOrdewellTool(request.name)) {
17910
- this.send(request.id, sessionId, ALLOW);
17911
- return;
17912
- }
17913
- onEvent(request);
17914
- this.send(request.id, sessionId, DENY);
17915
- }
17916
- /**
17917
- * A task's request. Under a mode whose manifest sets `approvals: auto` it is
18422
+ * A task's request, or a planner's (with `autoApprove` false) for the
18423
+ * planner's envelope to answer (ADR-0026). Under a mode whose manifest sets `approvals: auto` it is
17918
18424
  * answered at once with what `opencode run --auto` answers, so the same plan
17919
18425
  * follows the manifest (ADR-0001), and announced already
17920
18426
  * decided so the log still shows it. Any other mode leaves it open for an
@@ -18093,10 +18599,7 @@ async function streamEvents(connect, signal, onFrame, onConnected, onActivity) {
18093
18599
  // src/services/harness/OpenCodeV2.ts
18094
18600
  var ACTIVE_POLL_INTERVAL_MS = 1e3;
18095
18601
  var IDLE_POLLS_BEFORE_START = 3;
18096
- var PLANNER_RULES = [
18097
- { action: "question", resource: "*", effect: "deny" },
18098
- { action: "edit", resource: "*", effect: "deny" }
18099
- ];
18602
+ var PLANNER_RULES = Object.entries(OPENCODE_PLANNER_PERMISSION).map(([action, effect]) => ({ action, resource: "*", effect }));
18100
18603
  var TASK_RULES = [{ action: "question", resource: "*", effect: "deny" }];
18101
18604
  function newTurnState() {
18102
18605
  return { ...streamTurn(), toolNames: /* @__PURE__ */ new Map(), stepModels: /* @__PURE__ */ new Map(), briefs: /* @__PURE__ */ new Map(), streamedMessages: /* @__PURE__ */ new Set() };
@@ -18143,6 +18646,7 @@ var OpenCodeV2 = class {
18143
18646
  return this.sessionId;
18144
18647
  }
18145
18648
  async start() {
18649
+ await this.exposeOrdewellTools();
18146
18650
  const resume = this.opts.resumeSessionId;
18147
18651
  if (resume) {
18148
18652
  const existing = await this.host.json("GET", `/api/session/${resume}`).catch(() => null);
@@ -18164,6 +18668,19 @@ var OpenCodeV2 = class {
18164
18668
  this.sessionId = created.data.id;
18165
18669
  await this.applyInstructions();
18166
18670
  }
18671
+ /**
18672
+ * 2.x lists an MCP server's tools inside its code-mode `execute` tool, where
18673
+ * a model told to call `task_complete` does not look for it. The server is
18674
+ * replaced at runtime with `codemode: false`, which lists its tools as tools
18675
+ * of their own: the config it started with is in the 1.x shape both versions
18676
+ * read, and that shape drops the setting. If the route is gone, the tools
18677
+ * are still reachable through `execute`.
18678
+ */
18679
+ async exposeOrdewellTools() {
18680
+ const mcp = this.opts.mcp;
18681
+ if (!mcp) return;
18682
+ await this.host.request("PUT", `/api/experimental/mcp/${mcp.name}`, { config: { type: "remote", url: mcp.url, headers: mcp.headers, codemode: false } }).catch(() => void 0);
18683
+ }
18167
18684
  /** Allow the Ordewell server's tools, so a call never waits on a person (ADR-0022, S3). */
18168
18685
  ordewellRules() {
18169
18686
  return this.opts.mcp ? OPENCODE_ORDEWELL.sessionRules() : [];
@@ -18424,12 +18941,14 @@ var OpenCodeV2 = class {
18424
18941
  ...ask.source?.id ? { toolUseId: ask.source.id } : {}
18425
18942
  };
18426
18943
  }
18427
- /** A planner's request is refused; a task's is answered at once or left open for a card. */
18944
+ /** A planner's request is held for its envelope (ADR-0026); a task's is answered at once or left open for a card. */
18428
18945
  onPermissionAsked(ask, session, state, onEvent) {
18429
18946
  const request = this.permissionRequest(ask);
18430
18947
  if (!request) return;
18431
- if (this.role !== "task") this.permissions.refuse(request, session, state.seen, onEvent);
18432
- else this.permissions.ask(request, session, this.opts.kind === "task" && autoApproves(this.opts), state.seen, onEvent);
18948
+ if (this.role !== "task") {
18949
+ const target = openCodePlannerAsk(request.name, { patterns: ask.resources }, this.host.mcpServers?.() ?? []);
18950
+ this.permissions.ask({ ...request, ask: target }, session, false, state.seen, onEvent);
18951
+ } else this.permissions.ask(request, session, this.opts.kind === "task" && autoApproves(this.opts), state.seen, onEvent);
18433
18952
  }
18434
18953
  };
18435
18954
 
@@ -18447,7 +18966,9 @@ var DISABLED_TOOLS = {
18447
18966
  edit: false,
18448
18967
  write: false,
18449
18968
  apply_patch: false,
18450
- todowrite: false
18969
+ todowrite: false,
18970
+ bash: false,
18971
+ task: false
18451
18972
  };
18452
18973
  var TASK_DISABLED_TOOLS = { question: false };
18453
18974
  function describeError(err) {
@@ -18518,6 +19039,8 @@ var OpenCodeAdapter = class {
18518
19039
  taskTurn = null;
18519
19040
  /** An abort was posted during the current task turn, so the idle that follows ends it as interrupted. */
18520
19041
  interruptRequested = false;
19042
+ /** 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). */
19043
+ mcpServers = [];
18521
19044
  permissions = new OpenCodePermissions(
18522
19045
  (id, sessionId, decision) => this.replyPermission(id, sessionId, permissionReply(decision, "reply")),
18523
19046
  () => this.ordewell
@@ -18550,7 +19073,8 @@ var OpenCodeAdapter = class {
18550
19073
  command: "opencode",
18551
19074
  args: ["serve", "--hostname", "127.0.0.1", "--port", "0"],
18552
19075
  env: (workspaceEnv) => {
18553
- const ordewellConfig = opts.mcp ? OPENCODE_ORDEWELL.configContent(workspaceEnv[OPENCODE_CONFIG_VARIABLE] ?? process.env[OPENCODE_CONFIG_VARIABLE], opts.mcp) : null;
19076
+ const existing = workspaceEnv[OPENCODE_CONFIG_VARIABLE] ?? process.env[OPENCODE_CONFIG_VARIABLE];
19077
+ 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;
18554
19078
  if (opts.mcp && ordewellConfig === null) {
18555
19079
  console.error("[opencode] OPENCODE_CONFIG_CONTENT is not a JSON object, so the Ordewell tools were not injected.");
18556
19080
  }
@@ -18608,7 +19132,8 @@ var OpenCodeAdapter = class {
18608
19132
  openEvents: (signal) => this.deps.fetch(`${this.baseUrl}/api/event`, { signal, headers: this.headers() }),
18609
19133
  processEnded: this.processEnded,
18610
19134
  isExited: () => this.exited,
18611
- exitMessage: () => this.exitMessage()
19135
+ exitMessage: () => this.exitMessage(),
19136
+ mcpServers: () => this.mcpServers
18612
19137
  }, { ...opts, mcp: this.ordewell ?? void 0 }, this.role());
18613
19138
  await this.v2.start();
18614
19139
  this.sessionId = this.v2.nativeSessionId();
@@ -19074,7 +19599,15 @@ var OpenCodeAdapter = class {
19074
19599
  const sessionId = ask.sessionID ?? this.sessionId ?? "";
19075
19600
  const name = permissionName(ask);
19076
19601
  if (!this.task) {
19077
- this.permissions.refuse({ type: "permission_request", id, name, detail: permissionDetail(ask) }, sessionId, seen, onEvent);
19602
+ this.permissions.ask({
19603
+ type: "permission_request",
19604
+ id,
19605
+ name,
19606
+ detail: permissionDetail(ask),
19607
+ input: ask.metadata ?? {},
19608
+ ...ask.tool?.callID ? { toolUseId: ask.tool.callID } : {},
19609
+ ask: openCodePlannerAsk(name, ask, this.mcpServers)
19610
+ }, sessionId, false, seen, onEvent);
19078
19611
  return;
19079
19612
  }
19080
19613
  const request = {
@@ -19131,10 +19664,21 @@ var OpenCodeAdapter = class {
19131
19664
  if (!ordewell || !this.baseUrl) return false;
19132
19665
  return awaitAttach(async () => {
19133
19666
  if (this.exited) return "failed";
19134
- const servers = await this.json("GET", "/mcp").catch(() => null);
19667
+ const servers = this.v2 ? await this.v2McpStatus() : await this.json("GET", "/mcp").catch(() => null);
19668
+ if (servers) this.mcpServers = Object.keys(servers).filter((name) => name !== ordewell.name);
19135
19669
  return OPENCODE_ORDEWELL.attachState(servers?.[ordewell.name]?.status);
19136
19670
  }, MCP_ATTACH_TIMEOUT_MS3, MCP_STATUS_POLL_MS2);
19137
19671
  }
19672
+ /** 2.x lists its servers as `{ name, status: { status } }`, and only once it has started connecting them. */
19673
+ async v2McpStatus() {
19674
+ const listed2 = await this.json("GET", "/api/mcp").catch(() => null);
19675
+ if (!Array.isArray(listed2?.data)) return null;
19676
+ const servers = {};
19677
+ for (const { name, status } of listed2.data) {
19678
+ if (typeof name === "string") servers[name] = typeof status?.status === "string" ? { status: status.status } : {};
19679
+ }
19680
+ return servers;
19681
+ }
19138
19682
  dispose() {
19139
19683
  if (this.disposed) return;
19140
19684
  this.disposed = true;
@@ -21721,6 +22265,10 @@ function plannerToolHandler(host) {
21721
22265
  }
21722
22266
  return { text: skill.content };
21723
22267
  },
22268
+ async runCommand({ command }, { signal }) {
22269
+ const outcome = await host.runCommand(command, signal);
22270
+ return { text: outcome.output || (outcome.success ? "(no output)" : "The command failed with no output."), ...outcome.success ? {} : { isError: true } };
22271
+ },
21724
22272
  async listRunners() {
21725
22273
  const catalog = await host.liveCatalog();
21726
22274
  return answer({
@@ -21970,6 +22518,47 @@ var TurnStream = class {
21970
22518
  }
21971
22519
  };
21972
22520
 
22521
+ // src/services/harness/plannerPermissions.ts
22522
+ 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.";
22523
+ 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.";
22524
+ var ALLOW2 = { decision: "allow" };
22525
+ var SUBJECT_MAX_CHARS = 400;
22526
+ function deny(note) {
22527
+ return { decision: "deny", note };
22528
+ }
22529
+ async function decidePlannerPermission(ask, input, gate) {
22530
+ if (!ask || ask.kind === "other") return deny(READ_ONLY_DENIAL);
22531
+ if (!gate) return deny("Nobody can approve this here. Continue without it.");
22532
+ switch (ask.kind) {
22533
+ case "mcp": {
22534
+ const args = JSON.stringify(input);
22535
+ const subject = `${ask.server ?? "MCP"} \xB7 ${ask.tool} ${args.length > SUBJECT_MAX_CHARS ? `${args.slice(0, SUBJECT_MAX_CHARS)}\u2026` : args}`;
22536
+ const granted = await gate.approval.request({
22537
+ kind: "mcp_tool",
22538
+ subject,
22539
+ scope: ask.scope,
22540
+ tool: ask.tool,
22541
+ detail: `The planner wants to use "${ask.tool}"${ask.server ? ` from ${ask.server}` : ""}. It may change things outside the repository.`
22542
+ });
22543
+ return granted ? ALLOW2 : deny(`Not approved: ${ask.scope}. ${OPS_HINT}`);
22544
+ }
22545
+ case "fetch":
22546
+ if (!gate.fetcher) return deny("This fetch cannot be approved here.");
22547
+ return await gate.fetcher.confirm(ask.url) ? ALLOW2 : deny(`Fetch denied (blocked host or not approved): ${ask.url}. ${OPS_HINT}`);
22548
+ case "path": {
22549
+ const { abs, inside } = resolveWithin(gate.workspaceRoot, ask.path);
22550
+ if (inside) return ALLOW2;
22551
+ const granted = await gate.approval.request({
22552
+ kind: "external_path",
22553
+ subject: abs,
22554
+ scope: grantScopeFor(abs, ask.directory ? "directory" : "file"),
22555
+ detail: `Planner research wants to read ${abs}, outside the workspace (${gate.workspaceRoot}).`
22556
+ });
22557
+ return granted ? ALLOW2 : deny(`Access denied: "${abs}" is outside the workspace and was not approved.`);
22558
+ }
22559
+ }
22560
+ }
22561
+
21973
22562
  // src/services/harness/CliAgentAiService.ts
21974
22563
  var MAX_AGENT_WAITS = 2;
21975
22564
  function waitForAgentsPrompt(running) {
@@ -22013,6 +22602,7 @@ var CliAgentAiService = class {
22013
22602
  * every session boundary — nothing from one goal may reach the next.
22014
22603
  */
22015
22604
  lastNativeSessionId = null;
22605
+ /** `gate` is what the agent's own tool requests are decided against; absent, only Ordewell's tools are allowed (ADR-0026). */
22016
22606
  conversation = null;
22017
22607
  mcpServer;
22018
22608
  /** The planner token the running process was spawned with; revoked with the process (ADR-0022, A3). */
@@ -22081,7 +22671,8 @@ var CliAgentAiService = class {
22081
22671
  };
22082
22672
  const tools = req.plannerTools;
22083
22673
  await this.startAdapter(startOptions, tools);
22084
- this.conversation = { startOptions, tools };
22674
+ const gate = req.approval ? { approval: req.approval, fetcher: req.fetcher, workspaceRoot: startOptions.cwd } : void 0;
22675
+ this.conversation = { startOptions, tools, gate };
22085
22676
  const opening = this.openingMessage(req);
22086
22677
  return this.runConversation(opening, req.onProgress, req.signal);
22087
22678
  }
@@ -22171,6 +22762,8 @@ var CliAgentAiService = class {
22171
22762
  segmentId = (0, import_uuid6.v4)();
22172
22763
  };
22173
22764
  const streamedThinking = /* @__PURE__ */ new Set();
22765
+ const deniedCalls = /* @__PURE__ */ new Set();
22766
+ const gate = this.conversation?.gate;
22174
22767
  const subagentUsage = /* @__PURE__ */ new Map();
22175
22768
  let error;
22176
22769
  let stepIndex = 0;
@@ -22225,7 +22818,7 @@ var CliAgentAiService = class {
22225
22818
  return;
22226
22819
  }
22227
22820
  case "tool_result":
22228
- settle(event.id, event.output, event.success, event.success ? "success" : "failure", event.subagentId);
22821
+ settle(event.id, event.output, event.success, event.success ? "success" : deniedCalls.has(event.id) ? "denied" : "failure", event.subagentId);
22229
22822
  return;
22230
22823
  case "usage": {
22231
22824
  const { subagentId } = event.record;
@@ -22253,18 +22846,28 @@ var CliAgentAiService = class {
22253
22846
  backgroundAgents++;
22254
22847
  return;
22255
22848
  case "permission_request": {
22256
- const mapped = mapAgentTool(event.name);
22257
- const args = JSON.stringify({ detail: event.detail });
22258
- if (!pendingCalls.has(event.id)) {
22259
- pendingCalls.set(event.id, { tool: mapped.tool, toolLabel: mapped.toolLabel, args });
22260
- onProgress({ type: "tool_call", tool: mapped.tool, toolLabel: mapped.toolLabel, toolArgs: args, toolCallId: event.id });
22849
+ const announceDenial = (output) => {
22850
+ const mapped = mapAgentTool(event.name);
22851
+ const args = JSON.stringify({ detail: event.detail });
22852
+ if (!pendingCalls.has(event.id)) {
22853
+ pendingCalls.set(event.id, { tool: mapped.tool, toolLabel: mapped.toolLabel, args });
22854
+ onProgress({ type: "tool_call", tool: mapped.tool, toolLabel: mapped.toolLabel, toolArgs: args, toolCallId: event.id });
22855
+ }
22856
+ settle(event.id, output, false, "denied");
22857
+ };
22858
+ if (!event.decided && adapter.answerPermission) {
22859
+ const { id, toolUseId } = event;
22860
+ void decidePlannerPermission(event.ask, event.input ?? {}, gate).catch(() => ({ decision: "deny" })).then((decision) => {
22861
+ if (decision.decision === "deny") {
22862
+ if (toolUseId) deniedCalls.add(toolUseId);
22863
+ else announceDenial(`Access denied: "${event.name}" was refused. ${decision.note ?? ""}`.trim());
22864
+ }
22865
+ adapter.answerPermission?.(id, decision);
22866
+ });
22867
+ return;
22261
22868
  }
22262
- settle(
22263
- event.id,
22264
- `Access denied: the planner runs read-only, so "${event.name}" was refused. Mutation belongs to the runners that execute the plan.`,
22265
- false,
22266
- "denied"
22267
- );
22869
+ if (event.decided?.decision !== "deny") return;
22870
+ announceDenial(`Access denied: the planner runs read-only, so "${event.name}" was refused. Mutation belongs to the runners that execute the plan.`);
22268
22871
  return;
22269
22872
  }
22270
22873
  case "error":
@@ -23634,7 +24237,7 @@ var HttpWebFetcher = class {
23634
24237
  }
23635
24238
  async fetch(url) {
23636
24239
  if (!await this.confirm(url)) {
23637
- return { success: false, output: `Fetch denied (blocked host or not approved): ${url}`, truncated: false };
24240
+ 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 };
23638
24241
  }
23639
24242
  return this.get(url, "Fetch");
23640
24243
  }
@@ -24736,8 +25339,9 @@ function createSession(deps) {
24736
25339
  checkpointQuestion: (taskId) => orchestrator.getCheckpointQuestion(taskId)
24737
25340
  });
24738
25341
  const approvalPolicy = new ApprovalPolicy({
24739
- mode: deps.config.approvalMode,
24740
- preApproved: deps.config.approvalPreApproved,
25342
+ // Read per request, so a change of autonomy level or allowlist applies to the next call.
25343
+ mode: () => effectiveApprovalMode(deps.config.approvalMode, deps.config.autonomousMode),
25344
+ preApproved: () => [...deps.config.approvalDefaults ? DEFAULT_PLANNER_ALLOWLIST : [], ...deps.config.approvalPreApproved],
24741
25345
  ask: (req) => approvals.ask(req),
24742
25346
  // The interactive path (`asked`) already broadcasts approval_request +
24743
25347
  // approval_settled; only the silent sources need a signal, or a
@@ -24840,7 +25444,8 @@ var Session = class {
24840
25444
  read: (signature, answer2) => this.conversation.read(signature, answer2),
24841
25445
  liveOutput: (taskId, opts) => this.orchestrator.getLiveOutput(taskId, opts),
24842
25446
  lastAttempt: (taskId) => lastAttemptDigest(this.taskLogLocation, taskId),
24843
- taskSkills: () => this.workspaceSkills()
25447
+ taskSkills: () => this.workspaceSkills(),
25448
+ runCommand: (command, signal) => this.fsAdapter.bash(command, signal)
24844
25449
  });
24845
25450
  constructor(parts) {
24846
25451
  this.config = parts.config;
@@ -25297,6 +25902,7 @@ var Session = class {
25297
25902
  contextWindow: this.modelResolver.contextWindowFor?.(this.config.orchestratorModel),
25298
25903
  fs: this.fsAdapter,
25299
25904
  fetcher: this.fetcher,
25905
+ approval: this.approvalPolicy,
25300
25906
  plannerTools: { sessionId: this.sessionId, handler: this.plannerTools },
25301
25907
  skills: this.workspaceSkills().listSkills()
25302
25908
  };
@@ -26201,8 +26807,8 @@ function taskRowView(task, context = {}) {
26201
26807
  function findNamed(tasks, id) {
26202
26808
  for (const task of tasks) {
26203
26809
  if (task.id === id) return task;
26204
- const nested = findNamed(task.subtasks ?? [], id);
26205
- if (nested) return nested;
26810
+ const nested2 = findNamed(task.subtasks ?? [], id);
26811
+ if (nested2) return nested2;
26206
26812
  }
26207
26813
  return void 0;
26208
26814
  }
@@ -27471,6 +28077,7 @@ var PlannerModelMemory = class {
27471
28077
  DAEMON_SUBPROTOCOL,
27472
28078
  DAEMON_TOKEN_SUBPROTOCOL_PREFIX,
27473
28079
  DEFAULT_MAX_PARALLEL,
28080
+ DEFAULT_PLANNER_ALLOWLIST,
27474
28081
  DEFAULT_RUNNERS,
27475
28082
  DENY_ALL,
27476
28083
  EMPTY_CONVERSATION,
@@ -27607,6 +28214,7 @@ var PlannerModelMemory = class {
27607
28214
  coerceAssignments,
27608
28215
  collectDirectDependencyOutputs,
27609
28216
  collectProviderCredentials,
28217
+ commandAllowedBy,
27610
28218
  composeAugmentedPrompt,
27611
28219
  composeContinuationPrompt,
27612
28220
  configuredProviders,
@@ -27633,6 +28241,7 @@ var PlannerModelMemory = class {
27633
28241
  discoverGeminiModels,
27634
28242
  drainNext,
27635
28243
  effectiveAllowlist,
28244
+ effectiveApprovalMode,
27636
28245
  emptyWarnings,
27637
28246
  enabledRunners,
27638
28247
  ensureDir,
@@ -27715,6 +28324,8 @@ var PlannerModelMemory = class {
27715
28324
  outputLines,
27716
28325
  outputPreview,
27717
28326
  ownerOnlyConfigFile,
28327
+ parseAllowlist,
28328
+ parseApprovalModeSetting,
27718
28329
  parseAutonomyLevel,
27719
28330
  parseMaxParallel,
27720
28331
  parsePartialPlan,
@@ -27772,6 +28383,7 @@ var PlannerModelMemory = class {
27772
28383
  savePrdMarkdown,
27773
28384
  saveSession,
27774
28385
  saveState,
28386
+ scopeAllowed,
27775
28387
  serializePlan,
27776
28388
  serializeTask,
27777
28389
  serializeTaskStatus,
@@ -27809,6 +28421,7 @@ var PlannerModelMemory = class {
27809
28421
  toTaskLogEvent,
27810
28422
  tokenSubprotocols,
27811
28423
  tokensMatch,
28424
+ toolAllowed,
27812
28425
  toolHeadline,
27813
28426
  trimToolOutput,
27814
28427
  truncateCheckpointSummary,