Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions openspec/changes/flag-level-permissions/.openspec.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-03
94 changes: 94 additions & 0 deletions openspec/changes/flag-level-permissions/design.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions openspec/changes/flag-level-permissions/proposal.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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`
48 changes: 48 additions & 0 deletions openspec/changes/flag-level-permissions/tasks.md
Original file line number Diff line number Diff line change
@@ -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
Loading