@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.
- package/README.md +25 -3
- package/dist/codex-plugin-pins.js +12 -12
- package/dist/hook-contract.d.ts +2 -2
- package/dist/hook-contract.d.ts.map +1 -1
- package/dist/hook-contract.js +8 -4
- package/dist/hook-contract.js.map +1 -1
- package/dist/hook-pre-action.d.ts +6 -1
- package/dist/hook-pre-action.d.ts.map +1 -1
- package/dist/hook-pre-action.js +276 -12
- package/dist/hook-pre-action.js.map +1 -1
- package/dist/hook-tool-policy.d.ts +13 -0
- package/dist/hook-tool-policy.d.ts.map +1 -1
- package/dist/hook-tool-policy.js +68 -4
- package/dist/hook-tool-policy.js.map +1 -1
- package/dist/host-approval-state.d.ts +17 -0
- package/dist/host-approval-state.d.ts.map +1 -1
- package/dist/host-approval-state.js +53 -0
- package/dist/host-approval-state.js.map +1 -1
- package/dist/normalized-action.d.ts +9 -3
- package/dist/normalized-action.d.ts.map +1 -1
- package/dist/normalized-action.js +241 -40
- package/dist/normalized-action.js.map +1 -1
- package/dist/own-server-identity.d.ts.map +1 -1
- package/dist/own-server-identity.js +10 -4
- package/dist/own-server-identity.js.map +1 -1
- package/dist/seatbelt/env-dump.d.ts +22 -1
- package/dist/seatbelt/env-dump.d.ts.map +1 -1
- package/dist/seatbelt/env-dump.js +27 -2
- package/dist/seatbelt/env-dump.js.map +1 -1
- package/dist/seatbelt/env-names.d.ts +22 -0
- package/dist/seatbelt/env-names.d.ts.map +1 -0
- package/dist/seatbelt/env-names.js +298 -0
- package/dist/seatbelt/env-names.js.map +1 -0
- package/dist/seatbelt/mapper.d.ts.map +1 -1
- package/dist/seatbelt/mapper.js +1247 -73
- package/dist/seatbelt/mapper.js.map +1 -1
- package/dist/seatbelt/tables.d.ts.map +1 -1
- package/dist/seatbelt/tables.js +4 -1
- package/dist/seatbelt/tables.js.map +1 -1
- package/dist/secret-paths.d.ts +23 -0
- package/dist/secret-paths.d.ts.map +1 -1
- package/dist/secret-paths.js +29 -2
- package/dist/secret-paths.js.map +1 -1
- package/dist/session-loop-guard.d.ts.map +1 -1
- package/dist/session-loop-guard.js +10 -1
- package/dist/session-loop-guard.js.map +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/hook-pre-action.js
CHANGED
|
@@ -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
|
-
|
|
660
|
-
|
|
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
|
-
|
|
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
|
|
679
|
-
|
|
680
|
-
|
|
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
|
|
932
|
+
fs,
|
|
684
933
|
git: { toplevel: () => null, getAll: () => [], currentBranch: () => null, remoteHead: () => null },
|
|
685
934
|
});
|
|
686
|
-
|
|
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,
|