Repository navigation
fix(lp1): map scoped allowed-tools grants such as Bash(git:*) by tool name - #768
Zhuoxi2000 wants to merge 3 commits into
Conversation
… name
_map_allowed_tools_to_categories looked up the whole allowed-tools entry,
so a scoped grant in the Tool(specifier) form (the Agent Skills spec's own
example syntax) mapped to no category. A skill declaring Bash(git:*) then
got LP1 HIGH for shell, while the broader bare Bash grant got nothing.
Look up only the tool name before an optional "(", bounding the search so
long specifiers are not copied. Add tests for the list, space and comma
forms and a negative control where Read(...) must not cover shell.
Signed-off-by: Edson <zhuoxi2000@gmail.com>
yashrajp22
left a comment
There was a problem hiding this comment.
The scoped-Bash fix passes the selected tests, but parsing declaration fragments introduces an LP1 regression. The inline comment includes reproduced cases from both source and fresh wheel scans.
| # Only the tool name before an optional ``(specifier)`` is looked up; | ||
| # bound the search so long specifiers are not copied or case-folded. | ||
| paren = tool.find("(", 0, 65) | ||
| name = tool[:paren] if paren != -1 else tool |
There was a problem hiding this comment.
Could we preserve and validate each complete scoped grant before looking up its tool name? For allowed-tools: "Read(./docs Bash(notes).md)" (also the comma variant), existing normalization splits inside the path. This new lookup then treats Bash(notes).md) as a Bash declaration and suppresses LP1 for subprocess.run(...), although only Read was declared. I reproduced LP1 on BASE and no LP1 on this head in both source and freshly built wheel scans; the equivalent YAML list correctly retains LP1.
allowed-tools: ["Bash("] also suppresses LP1 on this head. Please keep scope contents intact and reject incomplete declarations before category mapping, with regression coverage for these cases while preserving the new valid scoped-Bash behavior.
… ones
Splitting a string allowed-tools value on every comma or whitespace cut
scoped grants apart: "Read(./docs Bash(notes).md)" became "Read(./docs"
and "Bash(notes).md)", and the tool-name lookup then read the second
fragment as a Bash grant. LP1 was suppressed for shell code although
only Read was declared. An incomplete entry such as "Bash(" also mapped
to shell.
Add skillspector.allowed_tools with a splitter that only splits on
separators outside parentheses, choosing the comma form only when a
comma appears outside parentheses, and a validator that accepts a bare
tool name or Tool(specifier) with a non-blank, balanced specifier whose
closing parenthesis ends the entry. build_context and the LP analyzer
share the splitter, and only valid grants map to a category. Malformed
entries still count as an allowed-tools declaration for LP3 but cover
no capability.
Signed-off-by: Edson <zhuoxi2000@gmail.com>
|
Thanks @yashrajp22, good catch. The string splitter cut
New tests (21 cases):
On 4666a72, 17 of the 21 fail. The 4 that already pass there are guards: the YAML list case, the plain comma string in both parsers, and a stray
I also updated the PR description. It said the space form still splits |
yashrajp22
left a comment
There was a problem hiding this comment.
The previous scoped-entry splitting and incomplete-grant finding is fixed. Fresh source and wheel scans still reproduce the literal-parenthesis case described inline. All 59 selected HEAD tests passed.
| for match in _PARENTHESES.finditer(entry, paren + 1, len(entry) - 1): | ||
| depth += 1 if match.group() == "(" else -1 | ||
| if depth < 0: | ||
| return None | ||
| return name if depth == 0 else None |
There was a problem hiding this comment.
Could we allow literal parentheses inside an otherwise complete scope here? Claude Code's permission syntax treats parentheses within the specifier as literal characters. For example, allowed-tools: ["Bash(echo '(')"] is complete, but this balance check returns no tool. Scanning a fixture containing subprocess.run(["echo", "("], check=True) produces shell LP1 in both source and wheel modes; the closing-parenthesis variant and string form reproduce it too. This leaves a false positive for valid scoped Bash declarations that this PR intends to support. Please preserve rejection of incomplete outer grants while accepting literal specifier content, with coverage for these cases.
Claude Code's permission rule syntax treats parentheses inside a
Tool(specifier) specifier as literal characters. The grant validator
required them to balance and the string splitter counted nesting depth,
so a complete grant such as Bash(echo '(') or Bash(echo ')') mapped to
no tool, and LP1 was raised for shell code the grant covers. In a string
value, "Bash(echo '(') Read" was also kept as a single entry.
The validator now takes the specifier as everything between the first
"(" and the final ")", which must end the entry, and only requires it
to be non-blank. The splitter opens a specifier at "(" and closes it at
the first ")" followed by a comma, whitespace or the end of the value;
parentheses in between are literal. Incomplete grants such as "Bash(",
"Bash(notes" or "Bash(echo '('" and blank ones such as "Bash()" still
map to nothing, and "Read(./docs Bash(notes).md)" stays one Read grant.
Under the literal reading, the list entries "Bash(notes).md)" and
"Bash(a)(b)" are complete Bash grants, so their tests move from the
malformed cases to the valid ones.
Signed-off-by: Edson <zhuoxi2000@gmail.com>
|
Thanks @yashrajp22. You're right: the permission rule syntax says "Parentheses inside the specifier are literal", and the balance check rejected complete grants such as
Two cases that 2ca88a3 pinned as malformed are complete grants under the literal reading. I moved both tests explicitly:
Open points:
Tests (25 new or moved cases):
On 2ca88a3, 15 of the 25 fail, including the moved
I also updated the PR description, because it still listed |
Fixes #766
What
_map_allowed_tools_to_categoriesmaps a scoped grant in theTool(specifier)form by its tool name.Bash(git:*)maps toshell,WebFetch(domain:example.com)tonetwork,Read(./docs/**)tofile_readandEdit(src/**)tofile_write.Tool(specifier)where the specifier is everything between the first(and the final), that)ends the entry, and the specifier is not blank. As in Claude Code's permission rule syntax, parentheses inside the specifier are literal, soBash(echo '('),Bash(echo ')')andEdit(./notes (old)/**)are complete grants. Incomplete or blank entries such asBash(,Bash(notes,Bash(echo '('orBash()map to no category. They still count as anallowed-toolsdeclaration for LP3, as before. The existing 64-character bound on the tool name still applies.allowed-toolsvalues split only on separators outside a specifier, so a scoped grant is kept whole. A(opens a specifier, and the first)followed by a comma, whitespace or the end of the value closes it. Any(or)in between is literal."Bash(git status:*) Read"and"Bash(echo '(') Read"are two grants each, and"Read(./docs Bash(notes).md)"and"Read(./docs, Bash(notes).md)"are oneReadgrant each. The comma form is chosen only when a comma appears outside a specifier, so"Bash(git status:*) Read(./a, b)"is two grants. An unclosed(keeps the rest of the string in its entry, which the validator then rejects.build_contextand the LP analyzer now share that splitter through a new module,skillspector/allowed_tools.py, which also holds the validator the analyzer applies before mapping. The tool-name alphabet matches the settings permission-rule check inbundled_execution_surface.py. Unlike that check, this one does not require the specifier's parentheses to balance. The splitter is lazy, so a comma-dense declaration still stops at the analyzer's existing declaration limit without building a large list.Known limits:
)followed by a space or comma cannot be kept whole."Bash(echo ') x') Read"splits intoBash(echo '),x')andRead. This)boundary is what separates grants in the skills docs exampleBash(git add *) Bash(git commit *). In the YAML list form this analyzer keeps such an entry whole.Bash()(empty specifier) maps to no category. The docs do not define an empty specifier, so this errs toward raising LP1.)followed by anything other than a separator does not close the specifier, so a malformed entry such asRead(./docs)/**keeps the rest of the value, including a bareBashafter it. Onmainthat value split at the)and theBashwas recognised.Why
The lookup used to match the whole entry, so a scoped grant in the Agent Skills spec's own
Tool(specifier)form mapped to no category. A skill that declaredallowed-tools: Bash(git:*) Readand rangit statusthroughsubprocessgot LP1 HIGH forshell. The same skill with bareBashgot no finding. The narrower grant was penalised and the broader one was not. Details and the repro are in the linked issue.Mapping by tool name alone is not enough while the string forms split on every comma or whitespace. That split cut
Read(./docs Bash(notes).md)intoRead(./docsandBash(notes).md), and the second fragment would then have counted as a Bash grant even though onlyReadwas declared. Splitting outside a specifier and validating each grant before mapping keeps specifier text from being read as another tool. Treating parentheses inside the specifier as literal keeps grants such asBash(echo '(')from being rejected.This is separate from #685, which changed how the LP1/LP3 explanation and remediation text frame
allowed-toolsas preapproval. That change did not touch the tool-name lookup, and this one leaves its wording as it is.Tests
In
tests/test_mcp_least_privilege.py::TestLP3AllowedTools:test_allowed_tools_scoped_bash_covers_shell_no_lp1, parametrized over the list form["Bash(git:*)"], the space form"Bash(git:*) Bash(jq:*) Read", a space form with a space inside the specifier"Bash(git status:*) Read", the comma form"Bash(git status:*), Read", and the list entry["Bash(notes).md)"], whose specifier isnotes).mdtest_allowed_tools_bash_scope_with_literal_parens_covers_shell_no_lp1, which scanssubprocess.run(["echo", "("], check=True)with["Bash(echo '(')"],["Bash(echo ')')"],"Bash(echo '(') Read","Bash(echo ')') Read","Bash(echo '('), Read"and"Bash(echo ')'), Read", and requires no LP1 forshelltest_allowed_tools_incomplete_bash_scope_with_literal_parens_still_lp1, which scans the same code with["Bash(echo '('"],["Bash(echo ')'"],"Bash(echo '( Read"and"Bash(echo '(', Read", and requires LP1 forshelltest_allowed_tools_scoped_non_shell_tool_still_lp1, a negative control:Read(./docs/**)does not covershell, so LP1 is still raisedtest_map_allowed_tools_uses_tool_name_before_specifier, which checks the category mapping directly for Bash, WebFetch, Read and Edittest_allowed_tools_malformed_or_nested_bash_still_lp1, which requires LP1 forshellwith"Read(./docs Bash(notes).md)","Read(./docs, Bash(notes).md)", the list["Read(./docs Bash(notes).md)"],["Bash("],["Bash(notes"],"Bash(notes Read"and["Bash()"]test_normalize_allowed_tools_keeps_scoped_grants_whole, which checks the entries produced from the string forms, including literal(and)in a specifier and the)boundary abovetest_map_allowed_tools_ignores_malformed_grants, which checks that incomplete or blank entries map to nothing and that a specifier may contain parenthesestest_map_allowed_tools_treats_scope_parens_as_literal, which checks thatBash(echo '('),Bash(echo ')'),Bash(notes).md)andBash(a)(b)each map toshellIn
tests/nodes/test_build_context.py:test_build_context_keeps_scoped_allowed_tools_whole, which checks the entriesbuild_contextproduces from the same string forms in SKILL.md frontmatter, including literal parentheses in a specifierAll runs below use Python 3.12 with the dev dependencies from
uv.lock.Before, on
mainwith only the first three tests applied: 5 failed, 20 passed intests/test_mcp_least_privilege.py. On the first commit of this PR (4666a72), 17 of the 21 cases added by the second commit fail. On the second commit (2ca88a3), 15 of the 25 cases added or moved by the third commit (318b972) fail. The ones that already pass at each step are guards or splitter cases whose result did not change.After:
End to end,
skillspector scan --no-llmruns on a skill whosescripts/run.pycallssubprocess.run(['git', 'status']), orsubprocess.run(["echo", "("], check=True)for theechorows. The results for this PR are the same from source and from a freshly built wheel.allowed-toolsBash ReadBash(git:*) Bash(python3:*) ReadHIGH: LP1 - Code capability 'shell' detected in scripts/run.py ...[Bash(git:*)]shellBash(git status:*), ReadshellBash(git status:*) Readshell["Bash(echo '(')"],["Bash(echo ')')"]shellBash(echo '(') Read,Bash(echo ')') Read,Bash(echo '('), Read,Bash(echo ')'), Readshell["Bash(notes).md)"],["Bash(a)(b)"]shellBashgrants with specifiersnotes).mdanda)(bRead(./docs/**)shellRead(./docs Bash(notes).md)shellRead(./docs, Bash(notes).md)shell["Read(./docs Bash(notes).md)"]shell["Bash("],["Bash(notes"],Bash(notes Read,["Bash()"],["Bash(echo '('"]shellLint:
make lintandmake format-checkpass (ruff 0.15.19 fromuv.lock). Ruff v0.15.2, as pinned in.pre-commit-config.yaml, also passescheckandformat --checkon the changed files.mypy2.1.0 reports no issues inallowed_tools.pyormcp_least_privilege.py. Onbuild_context.pyit only reports the missing PyYAML stubs, whichmainreports too.