From 8960aa0aa9be221079c0048d86675bceca4cc33a Mon Sep 17 00:00:00 2001 From: Ildar Valiullin Date: Thu, 3 Sep 2026 03:20:38 +0300 Subject: [PATCH 1/3] docs(openspec): propose flag-level bash permissions change Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../flag-level-permissions/.openspec.yaml | 2 ++ .../flag-level-permissions/proposal.md | 28 +++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 openspec/changes/flag-level-permissions/.openspec.yaml create mode 100644 openspec/changes/flag-level-permissions/proposal.md diff --git a/openspec/changes/flag-level-permissions/.openspec.yaml b/openspec/changes/flag-level-permissions/.openspec.yaml new file mode 100644 index 0000000..9696e00 --- /dev/null +++ b/openspec/changes/flag-level-permissions/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-03 diff --git a/openspec/changes/flag-level-permissions/proposal.md b/openspec/changes/flag-level-permissions/proposal.md new file mode 100644 index 0000000..e5c4ac7 --- /dev/null +++ b/openspec/changes/flag-level-permissions/proposal.md @@ -0,0 +1,28 @@ +## Why + +`permission.bash` glob patterns match against the whole command string, so they cannot express flag-level intent. A user who wants `curl -X GET` allowed but every other `curl` invocation asked must resort to fragile full-string patterns — and ordering/overlap between broad and narrow patterns is undefined behavior territory (`"curl *": "ask"` catches `curl -X GET https://api.com` even when a narrower `"curl -X GET *": "allow"` exists elsewhere). Dangerous flags like `find ... -delete`, `git push --force`, or `rm -rf` deserve their own actions independent of the command's general permission. The plugin already splits commands into segments; it is the right place to evaluate segments against arg/flag-aware rules. + +## What Changes + +- **New config section `permission.bash_args`** — ordered array of rules `{ "pattern": "curl -X GET *", "action": "allow" }` matching a segment's command name and arguments as whitespace tokens +- **Token-based pattern matching** — `*` in a pattern matches any run of zero or more argument tokens (`curl -X GET *`, `find * -delete`); matching is anchored at the first token (the command name) +- **First-match-wins precedence** — rules are evaluated in declared array order; the first matching rule decides the segment's bash action. Specific rules before broad rules is the documented convention +- **Args rules take precedence over glob rules** — a segment is checked against `bash_args` first; if no args rule matches, existing `permission.bash` glob matching applies unchanged +- **Force-allow for flag rules** — when an args rule matches with `allow`, the plugin stores the decision and overrides a native opencode `ask` via `permission.ask` (`status = "allow"`), making `curl -X GET *` → allow genuinely effective even when the native fallback is `"curl *": "ask"`. Glob-only allows keep today's behavior (no intervention) +- **Chain integration** — args-rule actions participate in existing segment resolution and most-restrictive-wins chain aggregation; `deny`/`ask` still wrap and store as today + +## Capabilities + +### New Capabilities +- `args-permission-matching`: Parse `permission.bash_args` rules, match segments by command + argument tokens with wildcard support, resolve precedence, and enforce flag-level allow/ask/deny decisions + +### Modified Capabilities + +None (the base `opencode-bash-guard` change is not yet archived; behavioral extensions to `config-reader` and `enforcement` are expressed as added requirements inside `args-permission-matching`). + +## Impact + +- Config: optional new `permission.bash_args` array in `opencode.json` — absent section means zero behavior change +- Code: `src/config.ts` (new rule type, parsing, token matcher), `src/enforce.ts` (resolution order, stored allow decisions), `src/index.ts` (permission.ask may set `status = "allow"`) +- Docs: README section with rule examples and ordering guidance +- No new dependencies From c0594ca88c7110a842bce7eb9734d2e2f5d57480 Mon Sep 17 00:00:00 2001 From: Ildar Valiullin Date: Thu, 3 Sep 2026 03:21:00 +0300 Subject: [PATCH 2/3] docs(openspec): add design for flag-level permissions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changes/flag-level-permissions/design.md | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 openspec/changes/flag-level-permissions/design.md diff --git a/openspec/changes/flag-level-permissions/design.md b/openspec/changes/flag-level-permissions/design.md new file mode 100644 index 0000000..fd4ca70 --- /dev/null +++ b/openspec/changes/flag-level-permissions/design.md @@ -0,0 +1,94 @@ +## Context + +Segments arriving at `resolveSegment` are plain strings matched against glob patterns (`matchBashPermission`, last-match-wins). Two problems with flag-level control in this model: + +1. A char-level glob like `"* -delete *"` matches `-delete` anywhere — including quoted text and flag values (`git commit -m "-delete"`). Flag intent needs token awareness, not substring globs. +2. Precedence is order-dependent on JSON object keys with last-match-wins. Expressing "allow `curl -X GET *` but ask every other `curl`" is impossible to state reliably. + +There is also an enforcement asymmetry: today the plugin only intervenes on `ask`/`deny`. When it resolves `allow` it steps aside and native opencode permission applies its own full-string matching — so even a hypothetical narrow `allow` pattern would be overridden by a broader native `ask` (e.g. `"curl *": "ask"`). Flag rules that can *upgrade* a segment to allow need a mechanism to override the native decision. + +## Goals / Non-Goals + +**Goals:** +- Let users declare per-command arg/flag rules with an explicit action: `curl -X GET * → allow`, `find * -delete → ask` +- Deterministic precedence that does not depend on JSON object key order +- Rules work per segment inside chains (same evaluation path as glob rules) +- Flag `allow` can genuinely override a broader native `ask` +- Zero behavior change when `permission.bash_args` is absent + +**Non-Goals:** +- Not parsing flags into structured form (`{ flag, value }`) — pattern strings with `*` tokens are expressive enough and match the user's mental model +- Not shell-quote-aware tokenization in v1 (documented limitation) +- Not changing how glob rules match or how external_directory works +- Not de-obfuscating argv (aliases, `eval` re-parsing already handled upstream by chain.ts) + +## Decisions + +1. **Config shape: ordered array under `permission.bash_args`** + + ```json + { + "permission": { + "bash": { "*": "ask", "git *": "allow" }, + "bash_args": [ + { "pattern": "curl -X GET *", "action": "allow" }, + { "pattern": "curl *", "action": "ask" }, + { "pattern": "find * -delete", "action": "ask" }, + { "pattern": "find *", "action": "allow" }, + { "pattern": "git push --force *", "action": "deny" } + ] + } + } + ``` + + Array (not object) because ordering is the precedence mechanism and must be explicit. Invalid entries (missing pattern/action, bad action) are dropped with a warning, same policy as existing parsers. + +2. **Token matching, anchored at the command name** + + - Tokenize pattern and segment on whitespace (collapse runs of spaces/tabs). + - Pattern tokens match segment tokens one-to-one; a literal token must be equal (case-sensitive); `*` matches zero or more segment tokens (greedy with backtracking — standard glob-sequence DP). + - Matching is anchored: the first pattern token must equal the segment's first token (the command name). + - `curl -X GET *` → segment must be `curl`, then `-X`, then `GET`, then anything (or nothing). + - `find * -delete` → segment must start with `find`, contain `-delete` as a standalone token later, then anything. + - Case-sensitive: `-X GET` and `-x get` are distinct. Documented; no normalization in v1. + +3. **Precedence: args rules first (first-match-wins), then glob rules, then no match** + + Per-segment bash action resolution becomes: + + ``` + 1. first matching bash_args rule (declared order) → its action + 2. else existing glob matchBashPermission (last-match-wins) → its action + 3. else null (no opinion) + ``` + + First-match-wins over most-restrictive-wins is deliberate: the feature's core use case (`curl -X GET * → allow` above `curl * → ask`) requires a narrower rule to *override* a broader one. Most-restrictive-wins would collapse every overlapping match to `ask` and make the allow rule dead config. Deny semantics are preserved by declaration order: put `git push --force * → deny` before `git push * → allow`. Docs will call this out with a "specific first, broad last, denies before allows" convention. + +4. **Force-allow only for explicit args-rule matches** + + - When a segment's action comes from an args rule with `action: "allow"`, the plugin stores the decision (`callID → "allow"`) and `permission.ask` sets `output.status = "allow"`, overriding a native ask. Native allows never trigger `permission.ask`, so this only ever upgrades an ask → allow, never downgrades a deny. + - Glob-only allows keep today's behavior: no wrap, no store, native decides. + - Rationale: minimal behavior change — the only new override power is exactly the one the feature needs. Single-segment chains with one explicit flag-allow segment are force-allowed; multi-segment chains still aggregate to the most restrictive action first (an all-allow chain whose allows came from flag rules force-allows the ask). + +5. **Aggregation unchanged** + + `resolveChain` keeps deny > ask > allow across segments. `resolveSegment` gains the args-rule check as step 1; external_directory path checks still apply afterwards and merge most-restrictive within the segment. No new aggregation rules. + +6. **Where the decision comes from matters for storage** + + `StoredDecision` gains the resolved action including `allow`. `beforeExecute` stores: + - chain action `deny`/`ask` → store + wrap (today's flow) + - chain action `allow` **originating from at least one args-rule match** → store, no wrap + - chain action `allow` from glob rules only → no store, no wrap (unchanged) + - null → nothing (unchanged) + +7. **Tokenization limitation accepted** + + `echo "a b"` tokenizes to `echo`, `"a`, `b"`. A pattern could therefore match inside quoted strings. Accepted because (a) the plugin's job is gating, not parsing, (b) false matches err toward asking in the deny/ask direction users care about, and (c) argv-exact tokenization via unbash AST is a future refinement. + +## Risks / Trade-offs + +- **[First-match-wins misordering]** Users may declare broad rules first, shadowing narrow ones. Mitigation: README convention (specific → broad), warning when an identical-prefix broader rule precedes a narrower one is out of scope for v1. +- **[Quoted-token false matches]** `-delete` inside a quoted arg counts as a token. Mitigation: documented limitation; severity is an extra prompt (ask), not a bypass. +- **[Force-allow surprise]** A stored allow overrides native asks — a user relying on native `"curl *": "ask"` to review all curls will not be asked for `-X GET` matches. Mitigation: flag rules are opt-in; README states flag rules override native matching for matched segments. +- **[Case sensitivity]** `-x get` does not match `-X GET`. Mitigation: document; users add both spellings if needed. From 3b4891b6933480212875592a94a68668c0c91f69 Mon Sep 17 00:00:00 2001 From: Ildar Valiullin Date: Thu, 3 Sep 2026 03:21:00 +0300 Subject: [PATCH 3/3] docs(openspec): add args-permission-matching spec and tasks Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../specs/args-permission-matching/spec.md | 126 ++++++++++++++++++ .../changes/flag-level-permissions/tasks.md | 48 +++++++ 2 files changed, 174 insertions(+) create mode 100644 openspec/changes/flag-level-permissions/specs/args-permission-matching/spec.md create mode 100644 openspec/changes/flag-level-permissions/tasks.md diff --git a/openspec/changes/flag-level-permissions/specs/args-permission-matching/spec.md b/openspec/changes/flag-level-permissions/specs/args-permission-matching/spec.md new file mode 100644 index 0000000..9ade284 --- /dev/null +++ b/openspec/changes/flag-level-permissions/specs/args-permission-matching/spec.md @@ -0,0 +1,126 @@ +## ADDED Requirements + +### Requirement: Parse bash_args rules from config + +The system SHALL parse an optional `permission.bash_args` array from the merged opencode config into typed rules `{ pattern: string, action: "ask" | "allow" | "deny" }`, preserving declaration order. Entries with a missing/empty pattern or an invalid action SHALL be dropped with a warning. When the section is absent, the parsed rule list SHALL be empty and plugin behavior SHALL be identical to before this change. + +#### Scenario: Valid rules parse in order + +- **WHEN** `permission.bash_args` is `[{"pattern": "curl -X GET *", "action": "allow"}, {"pattern": "curl *", "action": "ask"}]` +- **THEN** two rules are stored in declaration order with those actions + +#### Scenario: Invalid entries dropped + +- **WHEN** `permission.bash_args` contains `{"pattern": "", "action": "allow"}` and `{"pattern": "find *", "action": "block"}` +- **THEN** both entries are dropped and a warning names each dropped entry + +#### Scenario: Section absent + +- **WHEN** `permission.bash_args` is not present in the config +- **THEN** the args rule list is empty and no behavior changes vs. the previous release + +### Requirement: Token-based pattern matching with wildcard support + +The system SHALL match a segment against an args-rule pattern by whitespace tokenization. A literal pattern token SHALL equal the segment token at the same position (case-sensitive); a `*` pattern token SHALL match any run of zero or more segment tokens. Matching SHALL be anchored at the first token (the command name) and require full consumption of pattern tokens. + +#### Scenario: Exact flag sequence + +- **WHEN** pattern is `curl -X GET *` and segment is `curl -X GET https://api.com/data` +- **THEN** the pattern matches + +#### Scenario: Wildcard between literals + +- **WHEN** pattern is `find * -delete` and segment is `find /tmp -name "*.log" -delete` +- **THEN** the pattern matches (`*` spans `/tmp -name "*.log"`) + +#### Scenario: Flag not present — no match + +- **WHEN** pattern is `find * -delete` and segment is `find /tmp -name "*.log"` +- **THEN** the pattern does not match + +#### Scenario: Trailing wildcard matches zero tokens + +- **WHEN** pattern is `curl -X GET *` and segment is `curl -X GET` +- **THEN** the pattern matches + +#### Scenario: Command name must match + +- **WHEN** pattern is `git push --force *` and segment is `hg push --force` +- **THEN** the pattern does not match + +#### Scenario: Case-sensitive tokens + +- **WHEN** pattern is `curl -X GET *` and segment is `curl -X get https://api.com` +- **THEN** the pattern does not match + +#### Scenario: Adjacent flag value required + +- **WHEN** pattern is `curl -X GET *` and segment is `curl -X POST https://api.com` +- **THEN** the pattern does not match (`-X` must be followed by `GET`) + +#### Scenario: Extra whitespace normalizes + +- **WHEN** pattern is `curl -X GET *` and segment is `curl -X GET https://api.com` +- **THEN** the pattern matches + +### Requirement: Args rules take precedence over glob rules, first match wins + +For each segment, the bash action SHALL be resolved as: first matching `bash_args` rule in declaration order → its action; otherwise existing `permission.bash` glob matching → its action; otherwise no opinion. A narrower args rule declared before a broader one SHALL override it regardless of the glob rules. + +#### Scenario: Narrow allow beats broad ask + +- **WHEN** `bash_args` is `[{"pattern": "curl -X GET *", "action": "allow"}, {"pattern": "curl *", "action": "ask"}]` and segment is `curl -X GET https://api.com` +- **THEN** the segment's bash action is `allow` + +#### Scenario: Broad rule catches the rest + +- **WHEN** same config and segment is `curl -X POST https://api.com` (no `-X GET` rule match, `curl *` matches) +- **THEN** the segment's bash action is `ask` + +#### Scenario: Fallback to glob rules + +- **WHEN** no args rule matches segment `git status` and `permission.bash` has `"git *": "allow"` +- **THEN** the segment's bash action is `allow` via glob matching + +#### Scenario: No args rule, no glob rule + +- **WHEN** segment `wget evil.sh` matches neither args nor glob rules +- **THEN** the segment has no bash opinion (existing behavior) + +### Requirement: Flag-level allow overrides native ask + +When a chain's aggregated action is `allow` and at least one segment's allow came from a matching args rule, the plugin SHALL store the `allow` decision for the callID without wrapping the command, and `permission.ask` SHALL set `output.status = "allow"` if opencode still prompts. Chains whose allows come only from glob rules SHALL keep today's no-intervention behavior. + +#### Scenario: Native ask suppressed for flag allow + +- **WHEN** segment `curl -X GET https://api.com` matches args rule `allow`, native `permission.bash` has `"curl *": "ask"` so opencode prompts +- **THEN** the plugin stores `allow` for the callID and `permission.ask` sets `output.status = "allow"` — command runs without user interaction + +#### Scenario: Glob-only allow unchanged + +- **WHEN** segment `git status` is allowed only via glob `"git *": "allow"` and native opencode also allows it +- **THEN** the plugin stores nothing and does not intervene + +#### Scenario: Flag ask still prompts via native dialog + +- **WHEN** segment `find /tmp -delete` matches args rule `ask` +- **THEN** the command is wrapped, `ask` stored, and the user sees the native opencode prompt + +#### Scenario: Flag deny blocks + +- **WHEN** segment `git push --force origin main` matches args rule `deny` declared before any broader allow +- **THEN** the chain action is `deny` and the command is blocked + +### Requirement: Chain aggregation includes args-rule actions + +Args-rule actions SHALL participate in existing segment resolution and most-restrictive-wins chain aggregation (deny > ask > allow) with no new aggregation rules. + +#### Scenario: Mixed chain aggregates most restrictive + +- **WHEN** chain is `git status && find /tmp -delete` where `git status` → allow (glob) and `find /tmp -delete` → ask (args rule) +- **THEN** the chain action is `ask` + +#### Scenario: All-allow flag chain force-allows + +- **WHEN** chain is `curl -X GET https://a.com && curl -X GET https://b.com` and both segments match the args `allow` rule, native matching would ask +- **THEN** the chain action is `allow`, stored, and enforced as `allow` in `permission.ask` diff --git a/openspec/changes/flag-level-permissions/tasks.md b/openspec/changes/flag-level-permissions/tasks.md new file mode 100644 index 0000000..98478a5 --- /dev/null +++ b/openspec/changes/flag-level-permissions/tasks.md @@ -0,0 +1,48 @@ +## 1. Config Parsing (`src/config.ts`) + +- [ ] 1.1 Add `ArgsPermissionRule { pattern: string; action: "ask" | "allow" | "deny" }` and extend `PluginConfig` with `bashArgsRules: ArgsPermissionRule[]` and a flag-source marker type for resolved allows +- [ ] 1.2 Parse `permission.bash_args` array in `parseConfig`: preserve order, validate pattern (non-empty string) and action, drop invalid entries with a per-entry warning +- [ ] 1.3 Implement token matcher: whitespace tokenization, `*` matches zero+ tokens (greedy with backtracking), anchored at first token, full pattern consumption, case-sensitive +- [ ] 1.4 Implement `matchArgsPermission(segment, rules)` returning `{ action } | null` — first matching rule wins +- [ ] 1.5 Write unit tests for all parsing scenarios in `specs/args-permission-matching/spec.md` + +## 2. Token Matcher Tests (pattern scenarios) + +- [ ] 2.1 Test exact flag sequence: `curl -X GET *` vs `curl -X GET https://api.com/data` → match +- [ ] 2.2 Test mid-pattern wildcard: `find * -delete` vs `find /tmp -name "*.log" -delete` → match +- [ ] 2.3 Test missing flag: `find * -delete` vs `find /tmp -name "*.log"` → no match +- [ ] 2.4 Test trailing wildcard zero tokens: `curl -X GET *` vs `curl -X GET` → match +- [ ] 2.5 Test command anchor: `git push --force *` vs `hg push --force` → no match +- [ ] 2.6 Test case sensitivity: `-X GET` vs `-X get` → no match +- [ ] 2.7 Test adjacent flag value: `curl -X GET *` vs `curl -X POST ...` → no match +- [ ] 2.8 Test whitespace normalization (multiple spaces) → match + +## 3. Resolution Integration (`src/enforce.ts`) + +- [ ] 3.1 Extend `resolveSegment`: check `matchArgsPermission` first, fall back to `matchBashPermission`, then external_directory checks (merge most-restrictive within segment) +- [ ] 3.2 Track whether the segment's allow originated from an args rule (propagate `allowFromArgsRule` through `resolveSegment` → `resolveChain` → `beforeExecute`) +- [ ] 3.3 Extend `StoredDecision` to represent `allow`; in `beforeExecute`, store `allow` only when chain action is `allow` AND at least one segment's allow came from an args rule (no wrap) +- [ ] 3.4 Verify chain aggregation unchanged: deny > ask > allow across segments including args-rule actions +- [ ] 3.5 Write unit tests for resolution-order scenarios in `specs/args-permission-matching/spec.md` + +## 4. Enforcement (`src/index.ts`, `src/enforce.ts`) + +- [ ] 4.1 Extend `handlePermissionAsk`: stored `allow` → `output.status = "allow"`; stored `ask`/`deny` unchanged; no stored decision → no opinion +- [ ] 4.2 Confirm `permission.ask` clears the stored decision after apply (no stale overrides) +- [ ] 4.3 Write unit tests: native ask suppressed for flag allow, glob-only allow untouched, flag ask prompts, flag deny blocks, mixed chain asks, all-allow flag chain force-allows + +## 5. Documentation + +- [ ] 5.1 README: new "Flag-level permissions" section — config example (`curl -X GET *` → allow, `curl *` → ask, `find * -delete` → ask, `git push --force *` → deny), ordering convention (specific first, broad last, denies before allows) +- [ ] 5.2 README: document that args rules take precedence over glob rules and flag `allow` overrides a native ask for matched segments +- [ ] 5.3 README known limitations: whitespace tokenization (quoted args split), case-sensitive flag matching + +## 6. Verification + +- [ ] 6.1 `npm test` — full suite green including new tests +- [ ] 6.2 `npm run build` — type-check passes +- [ ] 6.3 Manual: `curl -X GET https://api.com` with flag allow + native `"curl *": "ask"` → runs without prompt +- [ ] 6.4 Manual: `curl -X POST https://api.com` → native ask dialog appears +- [ ] 6.5 Manual: `find /tmp -name "*.log" -delete` → ask +- [ ] 6.6 Manual: `git push --force origin main` with deny rule → blocked +- [ ] 6.7 Manual: no `bash_args` section → behavior identical to previous release