66//
77// A rule fires when its tool-name `matcher` matches AND every constraint it declares also matches:
88// - `pathPattern` (a glob) must match some path-shaped string in the tool-call input, and/or
9- // - `inputIncludesAll` (substrings) must ALL appear in a single string-shaped input field (e.g. a command).
10- // A rule with neither constraint fires on the matcher alone. The built-in DEFAULT_DENY_RULES mirror the
9+ // - `inputIncludesAll` (substrings) must ALL appear in a single string-shaped input field (e.g. a command), and/or
10+ // - `inputTokenPattern` (a RegExp) must match a whole whitespace-separated token (quotes stripped) of a single
11+ // string-shaped input field — for flag-shaped needles like `-f`, where a substring test would also fire on
12+ // `--follow-tags`.
13+ // A rule with none of these constraints fires on the matcher alone. The built-in DEFAULT_DENY_RULES mirror the
1114// forbidden-path patterns enforced in `scripts/check-mcp-package.mjs` plus a conservative git force-push guard.
1215
1316/**
@@ -62,6 +65,15 @@ function collectInputStrings(input, seen = new WeakSet()) {
6265 return strings ;
6366}
6467
68+ /** Split a string-shaped input field into whitespace-separated tokens with surrounding quotes stripped —
69+ * shared by path-candidate expansion and flag-token matching below. */
70+ function splitTokens ( value ) {
71+ return value
72+ . split ( / \s + / )
73+ . map ( ( token ) => token . replace ( / ^ [ " ' ] + | [ " ' ] + $ / g, "" ) )
74+ . filter ( Boolean ) ;
75+ }
76+
6577/**
6678 * The candidate strings a path glob is tested against for one input value: the whole value AND each
6779 * whitespace-separated token (surrounding quotes stripped). A protected path is frequently embedded as one
@@ -71,12 +83,9 @@ function collectInputStrings(input, seen = new WeakSet()) {
7183 */
7284function pathCandidates ( value ) {
7385 const candidates = new Set ( [ value , normalizePathCandidate ( value ) ] ) ;
74- for ( const token of value . split ( / \s + / ) ) {
75- const trimmed = token . replace ( / ^ [ " ' ] + | [ " ' ] + $ / g, "" ) ;
76- if ( trimmed ) {
77- candidates . add ( trimmed ) ;
78- candidates . add ( normalizePathCandidate ( trimmed ) ) ;
79- }
86+ for ( const trimmed of splitTokens ( value ) ) {
87+ candidates . add ( trimmed ) ;
88+ candidates . add ( normalizePathCandidate ( trimmed ) ) ;
8089 }
8190 return [ ...candidates ] . filter ( Boolean ) ;
8291}
@@ -99,13 +108,18 @@ function ruleMatches(rule, toolName, inputStrings) {
99108 const needles = rule . inputIncludesAll . filter ( ( needle ) => typeof needle === "string" ) ;
100109 if ( ! inputStrings . some ( ( value ) => needles . every ( ( needle ) => value . includes ( needle ) ) ) ) return false ;
101110 }
111+ if ( rule . inputTokenPattern instanceof RegExp ) {
112+ if ( ! inputStrings . some ( ( value ) => splitTokens ( value ) . some ( ( token ) => rule . inputTokenPattern . test ( token ) ) ) ) {
113+ return false ;
114+ }
115+ }
102116 return true ;
103117}
104118
105119/**
106120 * The built-in house-rule deny set — a non-empty starting example a later phase can extend or replace. Mirrors the
107121 * forbidden-path regex in `scripts/check-mcp-package.mjs` (CI workflows, env files, secret-bearing paths, private
108- * key material) and adds a conservative git force-push guard (a command carrying both `push` and `-- force` ).
122+ * key material) and adds conservative git force-push guards (a command carrying `push` plus a force flag ).
109123 */
110124export const DEFAULT_DENY_RULES = [
111125 { matcher : "*" , pathPattern : "**/.github/workflows/**" , reason : "Never modify CI workflows (.github/workflows/**)." } ,
@@ -120,6 +134,10 @@ export const DEFAULT_DENY_RULES = [
120134 { matcher : "*" , pathPattern : "**/*private*key*" , reason : "Never touch private key material (**/*private*key*)." } ,
121135 { matcher : "*" , pathPattern : "**/*.pem" , reason : "Never touch PEM key material (*.pem)." } ,
122136 { matcher : "*" , inputIncludesAll : [ "push" , "--force" ] , reason : "Never force-push (git push --force)." } ,
137+ // Token-matched rather than substring-matched: a substring test for "-f" would also fire on an
138+ // unrelated long flag like --follow-tags. Matches a whole short-option token (bundled or not)
139+ // whose letters include "f", e.g. -f, -uf, -fu, but not a "--"-prefixed long flag.
140+ { matcher : "*" , inputIncludesAll : [ "push" ] , inputTokenPattern : / ^ - [ a - z ] * f [ a - z ] * $ / i, reason : "Never force-push (git push -f)." } ,
123141] ;
124142
125143/**
0 commit comments