@getmarrow/mcp 3.9.106 → 3.9.108

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.
Files changed (48) hide show
  1. package/README.md +25 -3
  2. package/dist/codex-plugin-pins.js +12 -12
  3. package/dist/hook-contract.d.ts +2 -2
  4. package/dist/hook-contract.d.ts.map +1 -1
  5. package/dist/hook-contract.js +8 -4
  6. package/dist/hook-contract.js.map +1 -1
  7. package/dist/hook-pre-action.d.ts +6 -1
  8. package/dist/hook-pre-action.d.ts.map +1 -1
  9. package/dist/hook-pre-action.js +276 -12
  10. package/dist/hook-pre-action.js.map +1 -1
  11. package/dist/hook-tool-policy.d.ts +13 -0
  12. package/dist/hook-tool-policy.d.ts.map +1 -1
  13. package/dist/hook-tool-policy.js +68 -4
  14. package/dist/hook-tool-policy.js.map +1 -1
  15. package/dist/host-approval-state.d.ts +17 -0
  16. package/dist/host-approval-state.d.ts.map +1 -1
  17. package/dist/host-approval-state.js +53 -0
  18. package/dist/host-approval-state.js.map +1 -1
  19. package/dist/normalized-action.d.ts +9 -3
  20. package/dist/normalized-action.d.ts.map +1 -1
  21. package/dist/normalized-action.js +241 -40
  22. package/dist/normalized-action.js.map +1 -1
  23. package/dist/own-server-identity.d.ts.map +1 -1
  24. package/dist/own-server-identity.js +10 -4
  25. package/dist/own-server-identity.js.map +1 -1
  26. package/dist/seatbelt/env-dump.d.ts +22 -1
  27. package/dist/seatbelt/env-dump.d.ts.map +1 -1
  28. package/dist/seatbelt/env-dump.js +27 -2
  29. package/dist/seatbelt/env-dump.js.map +1 -1
  30. package/dist/seatbelt/env-names.d.ts +22 -0
  31. package/dist/seatbelt/env-names.d.ts.map +1 -0
  32. package/dist/seatbelt/env-names.js +298 -0
  33. package/dist/seatbelt/env-names.js.map +1 -0
  34. package/dist/seatbelt/mapper.d.ts.map +1 -1
  35. package/dist/seatbelt/mapper.js +1247 -73
  36. package/dist/seatbelt/mapper.js.map +1 -1
  37. package/dist/seatbelt/tables.d.ts.map +1 -1
  38. package/dist/seatbelt/tables.js +4 -1
  39. package/dist/seatbelt/tables.js.map +1 -1
  40. package/dist/secret-paths.d.ts +23 -0
  41. package/dist/secret-paths.d.ts.map +1 -1
  42. package/dist/secret-paths.js +29 -2
  43. package/dist/secret-paths.js.map +1 -1
  44. package/dist/session-loop-guard.d.ts.map +1 -1
  45. package/dist/session-loop-guard.js +10 -1
  46. package/dist/session-loop-guard.js.map +1 -1
  47. package/package.json +1 -1
  48. package/server.json +2 -2
@@ -20,6 +20,8 @@ exports.seatbeltPassOutput = seatbeltPassOutput;
20
20
  exports.localLoopGuardDenyOutput = localLoopGuardDenyOutput;
21
21
  exports.classifyTool = classifyTool;
22
22
  exports.mayPrintEnvironment = mayPrintEnvironment;
23
+ exports.mayReadApprovalState = mayReadApprovalState;
24
+ exports.mayReadSecret = mayReadSecret;
23
25
  exports.cursorPreActionHookOutput = cursorPreActionHookOutput;
24
26
  exports.clinePreActionHookOutput = clinePreActionHookOutput;
25
27
  exports.windsurfPreActionDecision = windsurfPreActionDecision;
@@ -33,16 +35,20 @@ exports.runSeatbeltStatusCommand = runSeatbeltStatusCommand;
33
35
  exports.outageHolds = outageHolds;
34
36
  exports.runPreActionHookCommand = runPreActionHookCommand;
35
37
  const node_crypto_1 = require("node:crypto");
38
+ const node_fs_1 = require("node:fs");
36
39
  const node_os_1 = require("node:os");
40
+ const node_path_1 = require("node:path");
37
41
  const index_1 = require("./index");
38
42
  const request_reliability_1 = require("./request-reliability");
39
43
  const lifecycle_spool_1 = require("./lifecycle-spool");
40
44
  const control_state_1 = require("./control-state");
41
45
  const runtime_contract_1 = require("./runtime-contract");
42
46
  const host_approval_1 = require("./host-approval");
47
+ const host_approval_state_1 = require("./host-approval-state");
43
48
  const host_session_1 = require("./host-session");
44
49
  const own_server_identity_1 = require("./own-server-identity");
45
50
  const normalized_action_1 = require("./normalized-action");
51
+ const secret_paths_1 = require("./secret-paths");
46
52
  const session_loop_guard_1 = require("./session-loop-guard");
47
53
  const hook_tool_policy_1 = require("./hook-tool-policy");
48
54
  const hook_contract_1 = require("./hook-contract");
@@ -579,6 +585,9 @@ const CLASSIFICATION_SURFACES = ['production', 'github', 'npm', 'secrets', 'data
579
585
  * seatbelt is the one stricter difference). Only the pre-action hook decides (and holds) on what prints the environment.
580
586
  */
581
587
  function classifyTool(event, options = {}) {
588
+ const cwd = absoluteFolder(options.cwd) ?? absoluteFolder(event.cwd);
589
+ if (cwd !== options.cwd)
590
+ options = { ...options, cwd };
582
591
  const reading = (0, hook_tool_policy_1.toolPolicyReading)(event);
583
592
  if (!reading.shell.length && reading.base === 'named')
584
593
  return classificationFrom(classifyAsNamed(event, options));
@@ -649,22 +658,261 @@ function mayPrintEnvironment(command) {
649
658
  words = words.replace(ONE_NAME_READ, (read, name) => (exports.ENV_FILTER_SECRET_NAME.test(name) ? read : ''));
650
659
  if (ENV_DUMP_WORDS.test(words))
651
660
  return true;
661
+ if (globInFolderPart(words))
662
+ return true;
652
663
  // `ps` with an `e` or `E` anywhere after the first `ps` (an `e` after a later `ps` is after the first one too: one scan, no backtracking).
653
664
  const ps = /\bps\b/i.exec(words);
654
665
  if (ps && /[eE]/.test(words.slice(ps.index + 2)))
655
666
  return true;
656
667
  return EXPANSION.test(text) && NARROWED_WORDS.test(text);
657
668
  }
669
+ /**
670
+ * A path whose folder part holds a glob character can spell `/proc/self/task/1/environ` with neither word in it (`/p*\/self/e*`, `/???c/s*\/e*`,
671
+ * `/p[r]oc/self/en[v]iron`). A word that starts like a path (`/`, `~`, `.`, `$`), or any word after a `cd` (or when the hook itself runs in `/` or
672
+ * under `/proc`), is asked of the mapper when a glob sits in a folder of it. One pass over the words, no backtracking.
673
+ */
674
+ const WORD_BREAK = /[\s=:<>(;&|]+/;
675
+ const GLOB_CHAR = /[*?[]/;
676
+ const PATH_START = /^[/~.$]/;
677
+ const CHANGES_FOLDER = /\b(?:cd|pushd|popd)\b/;
678
+ function runsAtFilesystemRoot() {
679
+ try {
680
+ const cwd = process.cwd();
681
+ return cwd === '/' || cwd === '/proc' || cwd.startsWith('/proc/');
682
+ }
683
+ catch {
684
+ return false;
685
+ }
686
+ }
687
+ function globInFolderPart(words) {
688
+ const anywhere = CHANGES_FOLDER.test(words) || runsAtFilesystemRoot();
689
+ for (const word of words.split(WORD_BREAK)) {
690
+ const slash = word.lastIndexOf('/');
691
+ if (slash > 0 && (anywhere || PATH_START.test(word)) && GLOB_CHAR.test(word.slice(0, slash)))
692
+ return true;
693
+ }
694
+ return false;
695
+ }
658
696
  const MAPPER_UNSURE = /^(?:mapper_error|budget_)/;
659
- function shellPrintsEnvironment(command) {
660
- if (lastEnvDumpAnswer && lastEnvDumpAnswer.command === command)
697
+ /**
698
+ * False for a command that cannot read the host-approvals state file: the keyed hook then does not load the mapper for it on that account. A command that
699
+ * can spell the folder, or a folder that holds it, holds the word `approvals` or `marrow` (quotes and backslashes split a word without changing it), an
700
+ * ANSI-C quote, a glob in a folder part of a path that starts at the home (`~/.m*\/h*\/state.json`; `cd ~/.marrow; cat h*\/s*` holds `marrow`), or a tree walker (grep, rg, find, tar, cp, ...) given
701
+ * the home or the root, or given `.`/`..`/`*` while the hook runs in the home or the root. A path that only a computed name spells
702
+ * (`cd ~; cd .m*; cd h*; cat s*`) is read by the keyless mapper but not asked of it here.
703
+ */
704
+ const TREE_WALKERS = /\b(?:grep|egrep|fgrep|zgrep|rg|ag|ack|find|tar|zip|rsync|cp|scp|xargs|7z|bsdtar|cpio|pax)\b/;
705
+ const HOME_OR_ROOT_OPERAND = /(?:^|[\s=:'"])(?:~|\$HOME|\$\{HOME\}|\/home|\/Users|\/)\/?(?=[\s'";&|)]|$)/;
706
+ const CWD_RELATIVE_OPERAND = /(?:^|\s)(?:\.\.?|\*|\.\/\*|\.\*)\/?(?=\s|$)/;
707
+ function runsInHomeOrAbove() {
708
+ try {
709
+ const home = (process.env.HOME || (0, node_os_1.homedir)()).replace(/\/+$/, '');
710
+ const cwd = process.cwd().replace(/\/+$/, '') || '/';
711
+ return cwd === '/' || cwd === home || home.startsWith(`${cwd}/`);
712
+ }
713
+ catch {
714
+ return false;
715
+ }
716
+ }
717
+ const HOME_PATH_START = /^(?:~|\$HOME|\$\{HOME\}|\.|\/home\/|\/Users\/|\/root\/)/;
718
+ /** A glob in a folder part of a path that starts at the home or in the working folder (the env-dump filter keeps its own glob rule). */
719
+ function homeGlobInFolderPart(words) {
720
+ for (const word of words.split(WORD_BREAK)) {
721
+ const slash = word.lastIndexOf('/');
722
+ if (slash > 0 && GLOB_CHAR.test(word.slice(0, slash)) && HOME_PATH_START.test(word))
723
+ return true;
724
+ }
725
+ return false;
726
+ }
727
+ function mayReadApprovalState(command) {
728
+ if (command.includes("$'") || command.includes('$"'))
729
+ return true;
730
+ const text = command.replace(/['"\\]/g, '').toLowerCase();
731
+ if (text.includes('approvals') || text.includes('marrow') || homeGlobInFolderPart(text))
732
+ return true;
733
+ if (!TREE_WALKERS.test(text))
734
+ return false;
735
+ return HOME_OR_ROOT_OPERAND.test(command) || (CWD_RELATIVE_OPERAND.test(command) && runsInHomeOrAbove());
736
+ }
737
+ /**
738
+ * False for a command that cannot read a secret FILE inside code the keyed policy does not parse (a quoted `bash -c`, `eval`, `sudo sh -c`, `ssh host` or
739
+ * `xargs` string is one word to it). A command that can spell one holds a word of the secret-path table (src/secret-paths.ts: `.env`, `.npmrc`, `.ssh`, ...)
740
+ * or of the policy's secret file names, a word that starts with a dot and holds a glob or an expansion (`.e*`, `.e$X`), a variable set to a dot
741
+ * word beside any expansion (`A=.e; B=nv; cat $A$B`), or an expansion of a secret-looking variable (`$GITHUB_TOKEN`: ENV_FILTER_SECRET_NAME). Env and
742
+ * process words are not here (mayPrintEnvironment has them), nor are the programs that print a credential, nor an ANSI-C quote: the same text-only
743
+ * answer as before holds for those. A name that variables spell with no dot word in an assignment (`A=x.e`) is read by the keyless mapper but not asked
744
+ * of it here, as for the state file above. A dot word that only a brace spells (`cat .en{v,x}`) is not asked either: test/pre-action-hook.test.js pins it as
745
+ * not protected, and that expectation is left as it is.
746
+ */
747
+ const SECRET_FILE_WORDS = /(?<!process)\.env(?![a-z0-9_])|\.(?:ssh|aws|azure|kube|docker|gnupg|config|claude|marrow)|npmrc|netrc|pypirc|pgpass|git-credentials|dev\.vars|id_(?:rsa|dsa|ecdsa|ed25519)|hosts\.yml|settings\.local|htpasswd|my\.cnf|yarnrc|\.(?:pem|key|p12|pfx|jks|keystore|kdbx|ppk)\b/;
748
+ // A word (split at white space and `/`) with a dot that starts it, or follows one of `=:(;&|<>`, and a glob character or an expansion after that dot (`.e*`,
749
+ // `a=.e?v`, `.e$X`). One pass over the words and the first such dot only (a later dot's rest is part of its rest): linear, where one regular expression
750
+ // for it took seconds on a 100 KB command of `a=.b` repeated.
751
+ const DOT_LEAD = '=:(;&|<>';
752
+ function dotWordSpelled(text) {
753
+ for (const word of text.split(/[\s/]+/)) {
754
+ let dot = word.indexOf('.');
755
+ while (dot > 0 && !DOT_LEAD.includes(word[dot - 1]))
756
+ dot = word.indexOf('.', dot + 1);
757
+ if (dot !== -1 && /[*?[$`]/.test(word.slice(dot + 1)))
758
+ return true;
759
+ }
760
+ return false;
761
+ }
762
+ // A variable set to a dot word (`A=.e; B=nv; cat $A$B`) and an expansion anywhere: the name may be spelled by joining them.
763
+ const ASSIGNED_DOT_WORD = /(?:^|[\s;&|(])[a-z_]\w*=\.[^\s/;&|)]*/;
764
+ const SECRET_VARIABLE_USE = new RegExp(`\\$\\{?!?\\w*(?:${exports.ENV_FILTER_SECRET_NAME.source.toLowerCase()})`);
765
+ function mayReadSecret(command) {
766
+ const text = command.replace(/['"\\]/g, '').toLowerCase();
767
+ return SECRET_FILE_WORDS.test(text) || dotWordSpelled(text) || (text.includes('$') && ASSIGNED_DOT_WORD.test(text)) || SECRET_VARIABLE_USE.test(text);
768
+ }
769
+ /**
770
+ * `link` when a word of the command is a symbolic link, or a path through one (it reads as what it points at, whatever its name; the text alone cannot say);
771
+ * with `onlySecret` (a command the policy reads whole), only a link whose target is a secret path. `unread` when the command has more words than the limit.
772
+ * A word is read as a path from the working folder, the home (`~`, `$HOME`) and any folder the command names that exists (a `cd` target); words that hold an
773
+ * expansion or a glob are not paths yet. The mapper is then given the real `realpath` for this command, and only for it. A command with more words than the
774
+ * limit is asked of the mapper, so an unread word is never a pass.
775
+ */
776
+ const LINK_WORD_LIMIT = 2048;
777
+ // A subshell `(cat x)`, a substitution `$(cat x)` and a backtick pair `cat x` end a word with `)` or a backtick, so the words are read split there too
778
+ // (WORD_BREAK has neither). A name that holds one of those characters is still read whole: a word is a path in either spelling.
779
+ const LINK_WORD_BREAK = /[\s=:<>(;&|)`}]+/;
780
+ /** `value` when it is an absolute path (the keyless seatbelt's own test for an event's cwd, and no NUL byte), else undefined. */
781
+ function absoluteFolder(value) {
782
+ return typeof value === 'string' && value.startsWith('/') && !value.includes('\0') ? value : undefined;
783
+ }
784
+ function passwdHome() {
785
+ try {
786
+ const home = (0, node_os_1.userInfo)().homedir;
787
+ return typeof home === 'string' && home.startsWith('/') ? home : null;
788
+ }
789
+ catch {
790
+ return null;
791
+ }
792
+ }
793
+ /**
794
+ * The folders a command's words are read in. The hook process's own (its working folder and $HOME) are always one: that is the reading every earlier build
795
+ * gave, so no answer is looser than it. An event that names an absolute working folder adds the keyless seatbelt's reading of it (that folder and the passwd home),
796
+ * so a host that spawns the hook in another folder is read as the seatbelt reads it, a relative link name included. Any of them can hold the command.
797
+ */
798
+ function wordContexts(eventCwd) {
799
+ const contexts = [];
800
+ try {
801
+ contexts.push({ cwd: process.cwd(), home: process.env.HOME || (0, node_os_1.homedir)() });
802
+ }
803
+ catch { /* the hook's own folder is gone: the event's is read alone */ }
804
+ const eventContext = eventCwd === undefined ? null : { cwd: eventCwd, home: passwdHome() || '/nonexistent-home' };
805
+ if (eventContext && !contexts.some((own) => own.cwd === eventContext.cwd && own.home === eventContext.home))
806
+ contexts.push(eventContext);
807
+ return contexts;
808
+ }
809
+ function namesSymlink(command, onlySecret, contexts) {
810
+ const text = command.replace(/['"\\]/g, '');
811
+ const readable = (word) => word.length > 0 && word.length <= 4096 && !word.startsWith('-') && !/[*?[{$`]/.test(word.replace(/^\$\{?HOME\}?(?=\/|$)/, ''));
812
+ const whole = text.split(WORD_BREAK).filter(readable);
813
+ const known = new Set(whole);
814
+ const words = whole.concat(text.split(LINK_WORD_BREAK).filter((word) => readable(word) && !known.has(word)));
815
+ let unread = false;
816
+ for (const where of contexts) {
817
+ const answer = namesSymlinkIn(command, words, onlySecret, where);
818
+ if (answer === 'link')
819
+ return 'link';
820
+ if (answer === 'unread')
821
+ unread = true;
822
+ }
823
+ return unread ? 'unread' : 'none';
824
+ }
825
+ function namesSymlinkIn(command, words, onlySecret, where) {
826
+ const cwd = where.cwd;
827
+ const home = where.home.replace(/\/+$/, '');
828
+ const bases = [cwd];
829
+ const moved = (word, base) => {
830
+ const lexical = word === '~' || word.startsWith('~/') ? (0, node_path_1.resolve)(home, word.slice(2)) : word.startsWith('$HOME') || word.startsWith('${HOME}') ? (0, node_path_1.resolve)(home, word.replace(/^\$\{?HOME\}?\/?/, '')) : (0, node_path_1.resolve)(base, word);
831
+ // `/proc/self` and `/proc/thread-self` are links the kernel makes; a word under /proc is read by the env-dump words, not as a link.
832
+ if (lexical === '/proc' || lexical.startsWith('/proc/'))
833
+ return false;
834
+ try {
835
+ const real = (0, node_fs_1.realpathSync)(lexical);
836
+ return real !== lexical && (!onlySecret || (0, hook_tool_policy_1.isSecretPath)(real));
837
+ }
838
+ catch {
839
+ return false;
840
+ }
841
+ };
842
+ let reads = 0;
843
+ if (CHANGES_FOLDER.test(command)) {
844
+ for (const word of words) {
845
+ if (bases.length >= 9)
846
+ break;
847
+ const lexical = word.startsWith('/') ? word : (0, node_path_1.resolve)(cwd, word);
848
+ try {
849
+ if ((0, node_fs_1.statSync)(lexical).isDirectory())
850
+ bases.push(lexical);
851
+ }
852
+ catch { /* not a folder that exists */ }
853
+ }
854
+ }
855
+ for (const word of words) {
856
+ for (const base of bases) {
857
+ reads += 1;
858
+ if (reads > LINK_WORD_LIMIT)
859
+ return 'unread';
860
+ if (moved(word, base))
861
+ return 'link';
862
+ }
863
+ }
864
+ return 'none';
865
+ }
866
+ const fsGone = (error) => ['ENOENT', 'ENOTDIR'].includes(error?.code || '');
867
+ const fsDenied = (error) => ['EACCES', 'EPERM'].includes(error?.code || '');
868
+ const REAL_FS_VIEW = {
869
+ readFile: () => null,
870
+ realpath(path) { try {
871
+ return (0, node_fs_1.realpathSync)(path);
872
+ }
873
+ catch (error) {
874
+ if (fsGone(error))
875
+ return null;
876
+ throw error;
877
+ } },
878
+ readdir(path) { try {
879
+ return (0, node_fs_1.readdirSync)(path);
880
+ }
881
+ catch (error) {
882
+ if (fsGone(error) || fsDenied(error))
883
+ return null;
884
+ throw error;
885
+ } },
886
+ };
887
+ const TEXT_ONLY_FS_VIEW = { readFile: () => null, realpath: (path) => path };
888
+ // What the second run may add to the first: an S4 hit on a PATH (a secret file named, a link to one, a glob or pattern that can spell one, a secret folder
889
+ // walked). Never `ps_environment_loose` or a credential-printing command (`gh auth token`, `aws sts get-session-token`: not a read of a file), and never a
890
+ // reason the mapper only gives a word that is no path (the pattern of `grep .env README.md`) in a command the policy reads whole (policyReadsAllWords).
891
+ const SECOND_RUN_REASONS = /_secret_(?:path|pattern|dir)$|_secret_name$|_unknown_link$|^copy_secret_directory$|^tool_grep_glob_secret$|^tripwire_env_file$/;
892
+ // `eventCwd` comes first so classifyAsNamed can bind it and hand the policy a one-argument check.
893
+ function shellPrintsEnvironment(eventCwd, command) {
894
+ const contexts = wordContexts(eventCwd);
895
+ const where = contexts.map((context) => `${context.cwd}\0${context.home}`).join('\n');
896
+ if (lastEnvDumpAnswer && lastEnvDumpAnswer.command === command && lastEnvDumpAnswer.where === where)
661
897
  return lastEnvDumpAnswer.dumps;
662
- if (!mayPrintEnvironment(command))
898
+ const secretNamed = mayReadSecret(command);
899
+ const readsWhole = (0, hook_tool_policy_1.policyReadsAllWords)(command);
900
+ const links = namesSymlink(command, readsWhole, contexts);
901
+ // A command the policy reads whole is held when a link in it points at a secret path (the policy judges the target as it judges any path); any other
902
+ // command that names a link, or has too many words to read, is asked of the mapper.
903
+ const linkedSecret = readsWhole && links === 'link';
904
+ const linked = linkedSecret || (readsWhole ? links === 'unread' : links !== 'none');
905
+ if (!mayPrintEnvironment(command) && !mayReadApprovalState(command) && !secretNamed && !linked)
663
906
  return false;
907
+ if (linkedSecret) {
908
+ lastEnvDumpAnswer = { command, where, dumps: true };
909
+ return true;
910
+ }
664
911
  let dumps;
665
912
  try {
666
913
  if (envDumpTools === undefined) {
667
914
  try {
915
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
668
916
  envDumpTools = { mapper: require('./seatbelt/mapper'), reasons: require('./seatbelt/env-dump').ENV_DUMP_REASONS };
669
917
  }
670
918
  catch {
@@ -672,24 +920,37 @@ function shellPrintsEnvironment(command) {
672
920
  }
673
921
  }
674
922
  if (!envDumpTools)
675
- dumps = ENV_DUMP_WORDS.test(command);
923
+ dumps = ENV_DUMP_WORDS.test(command) || mayReadApprovalState(command) || secretNamed || linked;
676
924
  else {
677
925
  const { mapper, reasons } = envDumpTools;
678
- const decision = mapper.evaluateSeatbeltShell(command, {
679
- cwd: process.cwd(),
680
- home: process.env.HOME || (0, node_os_1.homedir)(),
926
+ const namesMarrow = /approvals|\.marrow/i.test(command.replace(/['"\\]/g, ''));
927
+ const ask = (fs, at) => mapper.evaluateSeatbeltShell(command, {
928
+ cwd: at.cwd,
929
+ home: at.home,
681
930
  tmpdir: '/tmp',
682
931
  platform: process.platform,
683
- fs: { readFile: () => null, realpath: (path) => path },
932
+ fs,
684
933
  git: { toplevel: () => null, getAll: () => [], currentBranch: () => null, remoteHead: () => null },
685
934
  });
686
- dumps = decision.hits.some((hit) => (hit.category === 'S4' && reasons.has(hit.reason)) || (MAPPER_UNSURE.test(hit.reason) && ENV_DUMP_WORDS.test(command)));
935
+ // The same answer holds both reads: the env-dump reasons, and the host-approvals state file: the reason the mapper adds to a hit on it, or
936
+ // any S4 or S0 stop of a command that names the folder or ~/.marrow (a wrapper, a copy, a walk that reaches it); and a mapper that cannot decide
937
+ // (an error, an exhausted budget) on a command that may read a secret.
938
+ const unsure = ENV_DUMP_WORDS.test(command) || mayReadApprovalState(command) || secretNamed || linked;
939
+ const holds = (decision, paths) => decision.hits.some((hit) => (hit.category === 'S4' && (reasons.has(hit.reason) || hit.reason === secret_paths_1.APPROVAL_STATE_REASON || (paths && SECOND_RUN_REASONS.test(hit.reason)))) || ((hit.category === 'S4' || hit.category === 'S0') && namesMarrow)
940
+ || (MAPPER_UNSURE.test(hit.reason) && unsure));
941
+ // The text alone is read first, exactly as before. A command that can name a secret file inside code the policy does not read (a wrapper, a glob, a
942
+ // variable, a name it cannot parse), or names a link, is then read again with the real `realpath` and `readdir` (no file contents): a word is a secret
943
+ // path only where the file is there (`process.env` in `grep -rn process.env src` names no file; the text alone cannot say), a link reads as its target
944
+ // and a glob as the names it expands to. Either run can hold the command, so no answer is looser than the text's.
945
+ if (!contexts.length)
946
+ throw new Error('no working folder');
947
+ dumps = contexts.some((at) => holds(ask(TEXT_ONLY_FS_VIEW, at), false) || (((secretNamed && !readsWhole) || linked) && holds(ask(REAL_FS_VIEW, at), true)));
687
948
  }
688
949
  }
689
950
  catch {
690
- dumps = ENV_DUMP_WORDS.test(command);
951
+ dumps = ENV_DUMP_WORDS.test(command) || mayReadApprovalState(command) || secretNamed || linked;
691
952
  }
692
- lastEnvDumpAnswer = { command, dumps };
953
+ lastEnvDumpAnswer = { command, where, dumps };
693
954
  return dumps;
694
955
  }
695
956
  /** The classification of a call under its own tool name only (every tool before the built-ins were hooked). */
@@ -703,7 +964,7 @@ function classifyAsNamed(event, options) {
703
964
  const input = (0, hook_tool_policy_1.toolClassificationText)(event).toLowerCase();
704
965
  // A look-alike of Marrow's own tool is never read-only, whatever its verb.
705
966
  // The PostToolUse path passes no check (options.postTool): the mapper is never loaded for it.
706
- const printsEnvironment = options.postTool === true ? undefined : shellPrintsEnvironment;
967
+ const printsEnvironment = options.postTool === true ? undefined : shellPrintsEnvironment.bind(null, options.cwd);
707
968
  const readOnly = options.ownUnproven !== true && (0, hook_tool_policy_1.isReadOnlyToolEventAsNamed)(event, printsEnvironment);
708
969
  const secretAccess = (0, hook_tool_policy_1.isSecretMaterialAccessAsNamed)(event, printsEnvironment);
709
970
  const protectedShellCommand = shellGoverned && (0, hook_tool_policy_1.isProtectedShellMutation)(command);
@@ -1252,6 +1513,9 @@ async function runPreActionHook(input, watch) {
1252
1513
  const sessionId = resolved.sessionId || source.session_id || source.conversation_id || source.task_id
1253
1514
  || (0, hook_contract_1.stableSessionWorkflowId)(undefined, [identity.harness, process.cwd()]);
1254
1515
  const agentId = identity.agent_id;
1516
+ // With a resolved key, the no-device-key fallback may make its approval state (nokeyFallbackKey); with none it never does.
1517
+ if (resolved.apiKey)
1518
+ (0, host_approval_state_1.allowNokeyFallbackCreate)();
1255
1519
  const correlation = (0, hook_contract_1.stableToolCorrelation)({ ...source, session_id: sessionId });
1256
1520
  const loopOperation = {
1257
1521
  sessionId,