feat/policy: allow-rule placeholders for a value that precedes a further subcommand #105

Merged
pti merged 1 commit from feat/subcommand-placeholders into main 2026-10-19 23:36:13 +02:00
Owner

Closes #52. An allow rule's subcommand list may now contain a placeholder segment spelled with a leading colon, for example issue, edit, :issue, title, so fj issue edit 50 title can be called. The tool is minted from the literal segments only as fj_issue_edit_title and each placeholder becomes a required schema parameter, typed number when its constraint says integer. The executor substitutes the value in place, before flags and before the double dash, so the value is not protected by the dash and gets its own guards, non-empty, no leading dash, no whitespace, no shell metacharacter, a strict shape of at most 256 characters, enforced by the policy layer and re-checked independently by the executor. Optional arg_constraints.placeholders narrows a value with the existing flag-constraint fields. Deny rules are checked exactly against the rule template as before and, for placeholder rules, again against the substituted path with a case-insensitive exact or prefix match, so deny issue, delete refuses allow issue, :x with x set to delete. That check matches literal text and cannot know a CLI's aliases, so the docs make a pattern allow-list the protection for an operand slot such as a kubectl resource and deny a backstop. Placeholder rules are operator-authored, the capability request path refuses them while the rules editor writes them under the same validation. Validation rejects a placeholder as the first segment, duplicate or malformed names, names clashing with a flag parameter or args, constraints for undeclared names, and two rules minting one tool name. Introspection and doctor drift skip placeholder rules so a template never reaches a help probe. The audit record carries the values. Two adversarial reviewer rounds with throwaway attack tests, flag injection at both layers, metacharacters and NUL, template leakage, argv ordering with two placeholders, name collisions, JSON number rendering, and the deny bypass found in round one and closed in round two. Design note at design/policy/subcommand-placeholders.md, docs in configuration.qmd, CHANGELOG Added bullet naming the remaining free-text obstacle for issue bodies. 🤖 Generated with Claude Code

Closes #52. An allow rule's subcommand list may now contain a placeholder segment spelled with a leading colon, for example issue, edit, :issue, title, so fj issue edit 50 title can be called. The tool is minted from the literal segments only as fj_issue_edit_title and each placeholder becomes a required schema parameter, typed number when its constraint says integer. The executor substitutes the value in place, before flags and before the double dash, so the value is not protected by the dash and gets its own guards, non-empty, no leading dash, no whitespace, no shell metacharacter, a strict shape of at most 256 characters, enforced by the policy layer and re-checked independently by the executor. Optional arg_constraints.placeholders narrows a value with the existing flag-constraint fields. Deny rules are checked exactly against the rule template as before and, for placeholder rules, again against the substituted path with a case-insensitive exact or prefix match, so deny issue, delete refuses allow issue, :x with x set to delete. That check matches literal text and cannot know a CLI's aliases, so the docs make a pattern allow-list the protection for an operand slot such as a kubectl resource and deny a backstop. Placeholder rules are operator-authored, the capability request path refuses them while the rules editor writes them under the same validation. Validation rejects a placeholder as the first segment, duplicate or malformed names, names clashing with a flag parameter or args, constraints for undeclared names, and two rules minting one tool name. Introspection and doctor drift skip placeholder rules so a template never reaches a help probe. The audit record carries the values. Two adversarial reviewer rounds with throwaway attack tests, flag injection at both layers, metacharacters and NUL, template leakage, argv ordering with two placeholders, name collisions, JSON number rendering, and the deny bypass found in round one and closed in round two. Design note at design/policy/subcommand-placeholders.md, docs in configuration.qmd, CHANGELOG Added bullet naming the remaining free-text obstacle for issue bodies. 🤖 Generated with Claude Code
feat(policy): allow-rule placeholders for a value that precedes a further subcommand
All checks were successful
Lint / lint (pull_request) Successful in 1m6s
Lint / sonarqube (pull_request) Has been skipped
25de1953db
wraptool builds every argv as <subcommand path> <flags> -- <positionals>,
so a CLI that takes a value in the middle of its subcommand path, such as
fj issue edit <ISSUE> title, could not be called: the issue number landed
after --, and clap read the following subcommand word as a positional
(#52).

An allow rule's subcommand may now contain :name placeholder segments, for
example [issue, edit, ":issue", title]. The MCP tool name is minted from
the literal segments only (fj_issue_edit_title) and each placeholder
becomes a required schema property. The executor substitutes the value
in place, before the flags and before --.

Because a placeholder value is not protected by --, it has its own guards:
it must be present, must not start with '-', must contain no whitespace or
shell metacharacter, and must match ^[A-Za-z0-9][A-Za-z0-9._/@:+-]{0,255}$.
The policy layer enforces them before execution and the executor re-checks
them independently, so either layer alone still refuses a bad value.
arg_constraints.placeholders narrows a value further with the existing
flag-constraint fields. Undeclared placeholder values are denied, and
denial reasons name the placeholder without quoting its value.

Rule matching is an exact match on the rule's own template, and deny rules
are checked exactly against it first, as for every rule. For a placeholder
rule, once its values pass their checks, the deny rules are checked again
against the path with the values substituted, denying on a
case-insensitive exact or prefix match: deny [issue, delete] then refuses
allow [issue, ":x"] with x=delete. Prefix and case folding there only deny
more; literal rules keep exact deny semantics. That check matches literal
text and cannot know a CLI's aliases (kubectl takes secret for secrets),
so the docs make a pattern allow-list the protection for an operand slot
and deny a backstop.

Placeholder rules are operator-authored: ApplyRequest refuses a requested
subcommand containing one, while the rules editor may write them under the
same validation. Config validation rejects a placeholder as the first
segment (it would let the caller pick the subcommand), duplicate or
malformed names, names that clash with a flag parameter or args,
constraints for undeclared names, and two rules of one tool that mint the
same tool name when either has a placeholder. Introspection and doctor's
drift check skip placeholder rules, whose path has no fixed help argv. The
audit record carries the placeholder values. An existing allow-rule
segment that starts with ':' is now read as a placeholder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pti merged commit 1d83edd2ff into main 2026-10-19 23:36:13 +02:00
pti deleted branch feat/subcommand-placeholders 2026-10-19 23:36:13 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
pti/wraptool!105
No description provided.