@cad0p/pi-steering-flags
Declarative flag-presence and flag-allowlist predicates for pi-steering rules. First official external plugin.
Package details
Install @cad0p/pi-steering-flags from npm and Pi will load the resources declared by the package manifest.
$ pi install npm:@cad0p/pi-steering-flags- Package
@cad0p/pi-steering-flags- Version
0.1.1- Published
- Aug 24, 2026
- Downloads
- 401/mo · 215/wk
- Author
- cad0p
- License
- MIT
- Types
- package
- Size
- 125.4 KB
- Dependencies
- 0 dependencies · 1 peer
Security note
Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.
README
pi-steering-flags
Declarative flag-presence and flag-allowlist predicates for pi-steering rules.
First official external plugin for the pi-steering ecosystem. Establishes the precedent pattern for every community plugin that follows.
Install
pnpm add pi-steering-flags
pi-steering-flags declares @cad0p/pi-steering as a peerDependency; install both together.
Usage
// .pi/steering/index.ts
import { defineConfig } from "@cad0p/pi-steering";
import flagsPlugin from "pi-steering-flags";
export default defineConfig({
plugins: [flagsPlugin],
rules: [
// Block `aws` invocations without --profile or AWS_PROFILE env.
{
name: "aws-requires-profile",
tool: "bash",
field: "command",
pattern: /^aws\s+[a-z]/,
unless: /^aws\s+(sts\s+get-caller-identity|configure)\b/,
when: {
requiresFlag: { flag: "--profile", env: "AWS_PROFILE" },
},
reason: "Always specify --profile (or AWS_PROFILE=) — never rely on the default profile.",
},
// Default-deny flag gating for `cr`.
{
name: "cr-allowlisted-flags-only",
tool: "bash",
field: "command",
pattern: /^cr\b/,
when: {
not: { infoOnly: true },
allowlistedFlagsOnly: {
allow: ["--all", "--description", "--reviewers"],
},
},
reason:
"Only --all, --description, --reviewers are allowed with `cr`. " +
"Everything else should be inferred from the commit message.",
},
],
});
Predicates
when.requiresFlag
Rule fires (command is BLOCKED) when none of the listed flag / env equivalents appear in the evaluated command.
Shorthand: requiresFlag: "--profile" is equivalent to requiresFlag: { flag: "--profile" }.
Object form:
requiresFlag: {
flag?: string; // one required flag
flags?: readonly string[]; // any one of several (OR)
env?: string; // env-var alternative (VAR=value shell prefix)
envs?: readonly string[]; // any one of several envs (OR)
}
At least one of flag / flags / env / envs must be specified. A malformed arg (empty object) does not fire — fail-open for the rule author's benefit (better than silent always-block).
Examples:
when: { requiresFlag: "--profile" }
when: { requiresFlag: { flag: "--profile", env: "AWS_PROFILE" } }
when: { requiresFlag: { flags: ["-n", "--namespace"] } }
when: {
requiresFlag: {
flag: "--region",
envs: ["AWS_REGION", "AWS_DEFAULT_REGION"],
},
}
when.requiresFlagValue
Rule fires (command is BLOCKED) when the LAST-wins value of any of the listed flag aliases is absent, valueless, or does not satisfy matches. Last-wins matches how gh / cobra / pflag parse repeated flags — gh pr merge -t "see #13" --subject "closes #12" merges with the subject "closes #12", so that's the value a guardrail must evaluate.
requiresFlagValue: {
flags: readonly string[]; // aliases of ONE logical flag (OR'd)
matches: RegExp; // pattern the effective value must satisfy
}
Spread-only (no bare shorthand: it needs both fields), like allowlistedFlagsOnly.
Absence is fail-closed: a flag that isn't there at all counts as unmet — a rule requiring a matching value must block when it can't verify one. A trailing valueless flag (gh pr merge -t foo --subject) also fires, with no fallback to the overridden -t foo. Malformed args (missing / empty / non-string flags, non-RegExp matches) are the exception: they fail open, same precedent as requiresFlag's empty-object behavior.
Worked example — gh pr merge must reference the issue it closes in its subject — a closing keyword or any bare #N reference (help invocations still pass via the info-only carve-out):
{
name: "pr-merge-needs-closing-keyword",
tool: "bash",
field: "command",
pattern: /^gh pr merge\b/,
when: {
not: { infoOnly: { extraFlags: ["-h"] } },
requiresFlagValue: {
flags: ["--subject", "-t"],
matches: /\b(closes?|fixe?s?|resolves?)\s+#\d+\b|(^|\s)#\d+/i,
},
},
reason:
"gh pr merge should reference the issue it closes — pass --subject (or -t) with a closing keyword.",
}
One parsing asymmetry to know: an attached-empty flag (--subject=) yields the real value "" (which won't match, so the rule fires), while a separated-empty one (--subject "") reads as valueless (null) and fires too.
Like the underlying helper, the separated form takes the next token blindly as the value: --subject --force reads the value as "--force" (which won't match, so the rule fires). Callers who need stricter parsing should post-check inside a when.condition.
when.allowlistedFlagsOnly
Rule fires when any --prefixed token is present that isn't in the allowlist.
allowlistedFlagsOnly: {
allow: readonly string[];
allowPrefixes?: readonly string[];
}
- Flags in
allowthat start with--automatically match their--flag=valueattached-value form. - Short flags (
-n,-h) don't get auto-prefix — useallowPrefixesif you need to allow an attached-value short form (e.g.-ofooviaallowPrefixes: ["-o"]). - Positional args (tokens not starting with
-) are ignored.
Example:
when: {
allowlistedFlagsOnly: {
allow: ["--all", "--description", "--reviewers"],
// Implicitly matches: --description=... and --reviewers=...
},
}
when.infoOnly
Rule fires when the command IS an info-only invocation — --help / --version by default. Token-level and quote-aware: a help token inside a quoted VALUE (gh pr merge --subject "see --help") does NOT count, so guardrails still apply to real operations that merely mention help text. This is the replacement for the removed INFO_ONLY regex, which matched on the normalized string and wrongly exempted such commands.
Shorthand: infoOnly: true checks the default set. infoOnly: false never fires.
Object form (additive-only):
infoOnly: {
extraFlags?: readonly string[]; // additional flags, checked in ADDITION to the default set
}
Nothing can remove the safe core. -h and -v are deliberately NOT in the default set — they are real operations in adversarial commands (docker run -v /data:/data, curl -v, kubectl -v 8, psql -h host). A plugin author who wants -h for their own CLI adds it via extraFlags and owns that security tradeoff.
Carve-out idiom: the when clause is an AND — a naive when: { infoOnly: true } would BLOCK on help (it requires the command to BE info-only). To ALLOW info-only invocations while everything else still evaluates, negate with not::
when: { not: { infoOnly: true } }
when: { not: { infoOnly: { extraFlags: ["-h"] } } }
Helpers (escape-hatch)
When the built-in predicates aren't enough, reach for these helpers inside when.condition:
import {
getFlagValue,
hasEnvAssignment,
hasFlag,
} from "pi-steering-flags";
when: {
condition: async (ctx) => {
if (ctx.input.tool !== "bash") return false;
const path = getFlagValue(ctx.input.args, "--description");
if (path === null) return false;
const result = await ctx.exec("test", ["-f", path], { cwd: ctx.cwd });
return result.exitCode !== 0;
},
}
hasFlag(args, flag)— bare orflag=valueform.getFlagValue(args, flags)— LAST-flag-wins value lookup;flagsis a single flag or an alias set. Recognizes separatedflag valueand attachedflag=value; fail-closed on a trailing valueless flag.hasEnvAssignment(envAssignments, name)— literal env-var name match.INFO_FLAGS— the default info-only set (["--help", "--version"]).isInfoOnly(args, extraFlags?)— token-level info-only detection: true when any ofINFO_FLAGS(plus optional additiveextraFlags) appears inargs. Quote-aware, so--helpinside a quoted value does NOT match; the attached form--help=xDOES.
getFlagValue scans right-to-left so the LAST occurrence wins, matching how gh / cobra / pflag CLIs parse repeated flags. The second argument accepts a single flag or an alias set — gh treats -t and --subject as one logical flag, so aliases are OR'd at every scanned position:
// gh pr merge -t "see #13" --subject "closes #12"
getFlagValue(ctx.input.args, ["-t", "--subject"]); // "closes #12"
It recognizes both --flag=value and --flag value, and is fail-closed on a trailing valueless flag: gh pr merge -t foo --subject returns null rather than falling back to the overridden -t foo (real pflag rejects that command line anyway). Like all helpers it is quote-aware via .value, so consumers migrating from hand-rolled .text + unquote scans get upgraded quote handling for free.
All helpers are quote-aware (read .value before falling back to .text) and handle undefined input gracefully.
Design
Why a plugin, not engine core?
Flag-presence and allowlist checks are opinionated policy:
- Which flags count as equivalent (short + long + env)?
- How aggressive should default-deny be?
- What counts as a "flag" (every
--prefixed token, or just--long)?
Reasonable plugins can disagree. Keeping this logic in a plugin lets it iterate on its own release cadence without committing the engine to decisions about every CLI's conventions.
If a second unrelated plugin ends up depending on hasFlag / getFlagValue / hasEnvAssignment, those primitives will be promoted into pi-steering core. For v0.1.0 they stay here.
Why Rule.when, not Rule.unless?
Both are valid slots. Rule.when is the canonical home for plugin-registered predicates (it's the named-lookup slot); Rule.unless is a regex / function slot. Predicates should live where the engine expects them.
Use Rule.unless for simple pattern carve-outs that shouldn't trigger predicate evaluation in the first place. For info-only carve-outs, use the not: { infoOnly: true } idiom instead (see when.infoOnly) — token-level detection beats a regex over the normalized string, which wrongly matches help text inside quoted values.
Why two predicates instead of one?
requiresFlag (must-have) and allowlistedFlagsOnly (must-not-have-outside-list) encode opposite intents. One predicate with both modes would be denser but harder to read at the call site — the rule's intent is clearer when the predicate name matches it.
Ecosystem discovery
Tagged with:
"pi-package"— surfaces alongside every pi extension."pi-steering-package"— surfaces specifically in pi-steering plugin listings (once a discovery page exists).
Use the same keywords in your own plugin's package.json for discoverability.
License
MIT