diff --git a/openspec/changes/chain-restructuring/.openspec.yaml b/openspec/changes/chain-restructuring/.openspec.yaml new file mode 100644 index 0000000..9696e00 --- /dev/null +++ b/openspec/changes/chain-restructuring/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-03 diff --git a/openspec/changes/chain-restructuring/design.md b/openspec/changes/chain-restructuring/design.md new file mode 100644 index 0000000..d31e4b6 --- /dev/null +++ b/openspec/changes/chain-restructuring/design.md @@ -0,0 +1,143 @@ +## Context + +The current implementation lets fully-allowed chains pass without interruption (`resolveChain` returns `allow` when every segment matches an allow rule; enforced by test "all segments allowed — chain let through"). Not-allowed multi-step commands surface to the human as an unreadable one-liner in the permission dialog. The README and the base design's Goals bullet still claim "multi-segment chains trigger ask (defense-in-depth)" — stale relative to implementation; to be corrected in docs. + +Prompt-level fixes (AGENTS.md instructions like "one command per tool call") are soft: probabilistic adherence, no verification, degradation in long sessions. They reduce frequency but cannot guarantee reviewability. + +**Review decision (recorded):** restructuring applies only to commands that are *not allowed* — the aim is human readability of commands a person must review. Allowed chains pass untouched. + +API facts verified against `@opencode-ai/plugin@1.18.6` types and the opencode monorepo source: + +- `permission.ask` output is `{ status: "ask" | "deny" | "allow" }` only — no reason/message field exists, so "deny with explanation" is impossible through that hook. +- Per repo search and issue anomalyco/opencode#19469, the `permission.ask` hook is not actually triggered by the permission engine in current opencode source — a separate reliability concern for this plugin's deny path, out of scope here but recorded. +- **`tool.execute.before` can block with a message by throwing** — thrown errors become tool results with `resultType: "error"`, and the error text reaches the model (`packages/core/src/session/runner/to-llm-message.ts:55-67`; official docs example: `throw new Error("Do not read .env files")`). + +## Goals / Non-Goals + +**Goals:** +- Deterministically reject unreadable one-liners that would otherwise go to human review as blobs, with an error message that teaches the model the compliant form +- Keep allowed commands untouched — zero friction on the allow path +- Keep the plugin's verification guarantee intact after restructuring: multi-line re-issues are parsed per line (newlines are already segment separators) and each line is checked individually +- Plugin tuning in a dedicated JSONC file, disabled by default +- Fix the stale README claim about defense-in-depth asks + +**Non-Goals:** +- Not enforcing AGENTS.md instructions — the plugin cannot and should not verify prompt compliance +- Not restructuring `allow` or `deny` flows (allowed = allowed; deny = forbidden regardless of format) +- Not adding length-based or token-based metrics — segment count and nesting depth cover the unreadable-mess cases +- Not adding retry counters or rate limits for repeated violations +- Not hot-reloading the config file — changes require an opencode restart (same as the existing config hook) +- **No learned/external classifier (Jev) in v1** — detection stays deterministic; the TypeSafe Jev backend is deferred and tracked in #22 + +## Decisions + +1. **Separate config file: `opencode-bash-guard.jsonc` in the opencode config dirs** + + Locations, in increasing precedence: + - Global: `~/.config/opencode/opencode-bash-guard.jsonc` (or `$XDG_CONFIG_HOME/opencode/…`) + - Project: `/.opencode/opencode-bash-guard.jsonc` + + Files are JSONC (comments, trailing commas) parsed with `jsonc-parser`. Objects deep-merge, project wins over global; scalars override. Missing file at a location is not an error. **Invalid JSONC → warning + `restructure` treated as disabled; the plugin's core chain-guard behavior is unaffected** (config failure must not brick or expand enforcement). + + Rationale: this revises the original "no custom config files" stance for *plugin-internal tuning* only. The split is principled: **what** is allowed/asked/denied stays in `opencode.json` `permission`; **how the plugin behaves** (thresholds, feature switches) lives in the plugin's own file, where comments can document the trade-offs. File reads happen once at plugin init (`input.directory` gives the project root); changes require a restart. + +2. **Schema: `restructure` with nested fields, disabled by default** + + ```jsonc + { + // Reject complex one-liners and ask the agent to restructure them + "restructure": { + "enabled": false, // default false — zero behavior change + "max_segments": 3, // max commands in a single-line chain + "max_depth": 2 // max $()/backtick/meta-command nesting + } + } + ``` + + - `enabled: false` (default) or missing `restructure` section → feature off, no behavior change. + - `max_segments` / `max_depth` must be positive integers; invalid values are dropped with a warning and the default applies. Missing fields fall back to defaults. + +3. **Scope: ask-resolving chains only** + + Rejection fires when the chain resolves to `ask` AND limits are exceeded. Rationale (review decision): allowed actions should be allowed; the aim is readable human review of not-allowed multi-step commands. Consequences: + - `allow` → untouched, always. + - `deny` → existing deny flow (wrap + stored deny). Restructuring a forbidden action is meaningless — the format is not the problem. + - Parse errors → existing fail-closed deny, unaffected. + - `null` (no plugin opinion) → untouched. Under the documented prerequisite (`"*": "ask"`), every uncovered segment matches the catch-all, so any chain that would reach a human resolves to `ask` — "not allowed" ⇔ `ask` in practice. Without the catch-all the plugin has no opinion and does not intervene. + - **Default permission settings are verified and accounted for by the existing self-disable logic** (`parseConfig` in `src/config.ts`). OpenCode's documented defaults are permissive: when no permission settings exist, **`bash` defaults to `"allow"`** — commands run without any prompt (only `external_directory` and `doom_loop` default to `"ask"`; [Permissions → Defaults](https://opencode.ai/docs/permissions/#defaults)). In that default state the plugin disables itself entirely (`enabled = false` when `permission.bash` is absent, a flat `"allow"`, or object `"*": "allow"`) — so out of the box the native behavior is allow-all and neither the chain guard nor `restructure` runs: the plugin never inserts prompts into the permissive default experience. `restructure` can therefore only fire when the user explicitly configured bash permission rules, and the `"*": "ask"` catch-all (documented prerequisite) is exactly the configuration where "not allowed ⇔ ask" holds. + + Flow in `beforeExecute`: parse → resolve chain (existing) → if action is `ask` AND `restructure.enabled` AND limits exceeded → **throw** (replaces wrap+store for that call). Otherwise existing flows verbatim. + +4. **Segment limit applies per line, to every command shape** + + - Command contains no newline → the whole command is one line: both `max_segments` and `max_depth` checks apply. + - Command contains a newline (multi-line script) → **each line is checked against `max_segments` individually**; `max_depth` still applies to the whole command. + + Review decision (recorded): multi-line is NOT a blanket exemption. A "multi-line" script whose lines are long `&&`/`||` chains is exactly as unreadable as a one-liner, and chains re-wrapped across a few lines are a frequent real shape — the segment limit must see them. Checking per line keeps the retry loop deadlock-free: the compliant form (one command per line) has every line at 1 segment, so a compliant re-issue always exists, while a 2-line script of 10-segment chains is rejected with the same guidance. For multi-line commands the rejection message names the worst offending line (line number + its segment count) so the model can fix that line. + +5. **Rejection message must be actionable** + + Thrown error text (single source of truth, exported message builder): + + ``` + [opencode-bash-guard] Complex one-liner rejected (4 chained commands, nesting depth 2). + Re-issue as separate bash tool calls, or as a multi-line script with one command + per line — each command is then permission-checked individually. + ``` + + For a multi-line command the message names the worst offending line instead of the total: + + ``` + [opencode-bash-guard] Complex command rejected (line 2: 5 chained commands, nesting depth 2). + Re-issue as separate bash tool calls, or as a multi-line script with one command + per line — each command is then permission-checked individually. + ``` + + For an over-threshold interpreter inline script the guidance is interpreter-specific: + + ``` + [opencode-bash-guard] Complex inline script rejected (node -e: 9 statements). + Re-issue with one statement per line inside the quoted script, as separate bash + tool calls, or move the script to a file — each statement/command is then + readable and permission-checked individually. + ``` + + The message contains the actual counts (so the model can self-correct) and the compliant forms. Throwing means the command never executes and no permission dialog appears — the model retries on its own. + +6. **Restructured output is still fully verified** + + A multi-line re-issue is parsed by the existing `parseChain` into per-line segments; a separate-calls re-issue produces single-segment commands. Both paths run through the normal permission evaluation. The steering loop therefore cannot create a bypass — it only changes formatting. + +7. **README correction + AGENTS.md snippet** + + - Remove/correct the "multi-segment chains trigger ask (defense-in-depth)" claims in README (and the example table row `git status && git log → ask`) to match implemented behavior: fully-allowed chains pass through. + - New "Readable commands" section: `opencode-bash-guard.jsonc` example, threshold semantics, and a recommended AGENTS.md snippet (soft layer that reduces rejection frequency): + + ```markdown + ## Bash command style + - Issue one command per tool call. For sequences, use separate bash calls. + - Never write chained one-liners (`a && b && c`). If rejected, split and retry. + ``` + +8. **Repeated violations: same rejection every time** + + No attempt counter in v1. A compliant re-issue always exists (multi-line, one command per line), so the loop terminates on compliance; the model can also give up or ask the user. Counters/escalation deferred until observed to be a problem. + +9. **Interpreter inline scripts are complexity-checked too** + + `python`/`python3 -c`, `perl -e`, `node -e`/`--eval`, `ruby -e`, `php -r`, and heredoc-scripted interpreters carry whole programs inside a single shell segment — a 40-statement `node -e` one-liner looks like one benign segment to shell-level metrics, so the segment/depth checks never see it. When `restructure` is enabled, the plugin adds an inline-script metric: **statement count inside the script string** (split on `;` and newlines — a deliberate crude heuristic; no per-language parsers in v1), compared against the same `max_segments` threshold. An over-threshold inline script on an ask-resolving chain is rejected with interpreter-specific guidance (see decision 5): re-issue with one statement per line inside the quoted script — `python -c`, `node -e`, and `perl -e` all accept multi-line script strings — or move the script to a file. This keeps the check deterministic and dependency-free; per-language AST parsing is out of scope for v1. + +## Future Directions (discussed in review, not in v1 scope) + +- **Plugin-injected instructions (no manual AGENTS.md step) — possible today, kept out of v1.** OpenCode's plugin API exposes the experimental hook `"experimental.chat.system.transform"`: `(input: { sessionID?: string; model: Model }, output: { system: string[] }) => Promise` ([source](https://github.com/anomalyco/opencode/blob/7945de208964a49300d7f770d1a71d078db9a4c4/packages/plugin/src/index.ts#L291-L296)). OpenCode calls it on every LLM request after assembling the base system prompt ([source](https://github.com/anomalyco/opencode/blob/7945de208964a49300d7f770d1a71d078db9a4c4/packages/opencode/src/session/llm/request.ts#L70-L76)), so a plugin can append guidance by mutating `output.system`. This would let `opencode-bash-guard` ship its "one command per line" guidance itself, with no user AGENTS.md step. It is experimental and only reduces rejection frequency; the deterministic rejection path does not depend on it. Real-world precedent: `@qforge/opencode-agents-explorer` is a plugin that auto-injects folder-level `AGENTS.md` files via hooks. For v1 we keep the manual `AGENTS.md` snippet; auto-injection can be added later behind an opt-in plugin flag. +- **Learned complexity/risk classification (TypeSafe "Jev") — decided: NOT in v1.** The AST-threshold approach (`max_segments`, `max_depth`, statement counts) is deterministic but brittle at the margins — "is this command readable?" is a fuzzy judgment that hand-written thresholds approximate. A System One-style structured-output classifier (typesafe.ai Jev: typed decisions with calibrated probabilities, fast and cheap, schema outputs that cannot hallucinate types) could score commands where thresholds disagree with intuition. Explicitly deferred: v1 ships the deterministic detector only, with no external dependency, no privacy surface, and no availability coupling on a security path. When pursued, it is an optional, off-by-default detector backend (`"detector": "ast" | "jev"`): the deterministic path stays the source of truth and the fallback when the service is unavailable; the privacy implication of commands leaving the machine must be documented; adoption gated on early-access availability. Full design + tasks tracked in **#22**. + +## Risks / Trade-offs + +- **[Retry loops burn tokens]** Mitigated structurally: a compliant one-command-per-line form has 1 segment per line and cannot violate `max_segments`, so the loop cannot deadlock. Depth- and inline-script-gated rejections may still retry; the message carries exact counts. +- **[False positives on legitimate pipelines]** `cat a | grep b | wc -l` (3 segments) passes defaults; a single-line 4-stage pipeline gets rejected. Mitigation: thresholds are user-configurable in the JSONC file; document raising `max_segments`. +- **[Throw suppresses the dialog for rejected ask-chains]** Intended: the model retries first; the human then reviews a readable form. Users who prefer to review the raw blob can disable the feature. +- **[JSONC dependency + two-location merge]** Adds `jsonc-parser`; merge precedence (project over global) must be documented to avoid confusion. Invalid files fail safe (feature off, warning). +- **[Config read once at startup]** Same limitation as the existing `config` hook; restart to apply. +- **[Inline-script statement counting is heuristic]** Splitting on `;`/newlines can miscount strings containing semicolons or multi-statement lines; the metric errs toward rejection of unreadable blobs, the guidance offers the compliant form, and per-language parsing is a possible refinement. +- **[Related but separate: `permission.ask` may never fire in current opencode]** Issue anomalyco/opencode#19469 suggests the deny path of this plugin may not hard-block. Out of scope here; needs its own verification and possibly a fix change. diff --git a/openspec/changes/chain-restructuring/proposal.md b/openspec/changes/chain-restructuring/proposal.md new file mode 100644 index 0000000..b743c30 --- /dev/null +++ b/openspec/changes/chain-restructuring/proposal.md @@ -0,0 +1,32 @@ +## Why + +Complex one-liners (`a && b && c`, nested `$()`, `eval`/`sh -c` wrappers) are hard for humans to review. Today a not-allowed multi-step command surfaces to the human as an unreadable blob in the permission dialog, and the agent has no incentive to write readable commands. Prompt-level guidance (AGENTS.md) is a soft constraint with no verification loop. The right fix is deterministic: reject the not-allowed one-liner with an instructive error so the agent re-issues it in a reviewable form. + +**Allowed actions stay allowed** — restructuring never touches chains that pass permission checks. The feature targets exactly the commands a human will be asked to review. + +The enforcement mechanism is verified against opencode's plugin API: an error thrown in `tool.execute.before` is converted into a tool result with `resultType: "error"`, and the error text is sent back to the model (official docs pattern: `throw new Error("Do not read .env files")`). The `permission.ask` hook cannot serve this role — its output carries only `status` with no message field. + +## What Changes + +- **Separate plugin config file `opencode-bash-guard.jsonc`** — read from the opencode config dirs (global `~/.config/opencode/`, project `.opencode/`), JSONC format (comments allowed), deep-merged with project over global. Permission *actions* stay in `opencode.json`; plugin *behavior tuning* lives here +- **`restructure` section with nested fields** — `{ "enabled": false, "max_segments": 3, "max_depth": 2 }`; **disabled by default** → zero behavior change +- **Complexity detection** — segment count and substitution nesting depth from the existing `unbash`-based chain parse; meta-commands (`eval`, `sh -c`, `bash -c`, `zsh -c`) count as segments; interpreter inline scripts (`python -c`, `node -e`, `perl -e`, …) get a statement-count check so whole programs can't hide inside one shell segment +- **Reject-with-guidance on ask-resolving chains only** — when enabled, limits exceeded, and the chain resolves to `ask` (not allowed → human review), the plugin throws in `tool.execute.before`; nothing executes, and the error text tells the model to re-issue as separate bash tool calls or a multi-line script with one command per line +- **Segment limit applies per line, to every shape** — single-line chains and each line of a multi-line script alike; multi-line (one command per line) is still the always-compliant form, so a compliant re-issue always exists, but a "multi-line" script of long `&&`-chains per line is rejected like the one-liner it is. Substitution nesting depth is checked across the whole command. Pretty formatting (newline-separated statements) is the compliant form for interpreter inline scripts +- **README correction** — remove the stale "multi-segment chains trigger ask (defense-in-depth)" claim (implementation and spec say fully-allowed chains pass through); document the JSONC config and an AGENTS.md snippet (soft layer reducing rejection frequency) + +## Capabilities + +### New Capabilities +- `chain-restructuring`: Detect overly complex one-liner bash commands that would require human review and reject them with actionable guidance so the agent re-issues readable, individually-checkable commands + +### Modified Capabilities + +None (README behavioral correction is documentation, not a spec change; the base `opencode-bash-guard` change is not yet archived). + +## Impact + +- New optional config file `opencode-bash-guard.jsonc` — absent or `restructure.enabled: false` means zero behavior change +- New dependency: `jsonc-parser` (JSONC parsing with comments/trailing commas) +- Code: `src/chain.ts` (expose nesting depth), new config-loader module (file discovery, JSONC parse, merge, validation), `src/enforce.ts` (reject path in `beforeExecute`) +- Docs: README new section + behavior correction + AGENTS.md snippet diff --git a/openspec/changes/chain-restructuring/specs/chain-restructuring/spec.md b/openspec/changes/chain-restructuring/specs/chain-restructuring/spec.md new file mode 100644 index 0000000..9138006 --- /dev/null +++ b/openspec/changes/chain-restructuring/specs/chain-restructuring/spec.md @@ -0,0 +1,174 @@ +## ADDED Requirements + +### Requirement: Load plugin config from opencode-bash-guard.jsonc + +The system SHALL load its own configuration from `opencode-bash-guard.jsonc` files located in the opencode config dirs — global (`~/.config/opencode/` or `$XDG_CONFIG_HOME/opencode/`) and project (`/.opencode/`) — parsed as JSONC (comments and trailing commas allowed). Objects SHALL deep-merge with project precedence over global. A missing file SHALL not be an error. Invalid JSONC SHALL produce a warning and disable the `restructure` feature while leaving the plugin's core chain-guard behavior unchanged. Config SHALL be read once at plugin init; changes require a restart. + +#### Scenario: No config file anywhere — feature off + +- **WHEN** neither the global nor the project `opencode-bash-guard.jsonc` exists +- **THEN** `restructure` is disabled and plugin behavior is identical to before this change + +#### Scenario: Project overrides global + +- **WHEN** global config has `"restructure": { "enabled": true, "max_segments": 3 }` and project config has `"restructure": { "max_segments": 5 }` +- **THEN** the effective config is `restructure` enabled with `max_segments: 5` and `max_depth` from the global/defaults + +#### Scenario: JSONC syntax accepted + +- **WHEN** a config file contains `//` comments, a trailing comma, and valid JSON structure +- **THEN** it parses successfully + +#### Scenario: Invalid JSONC fails safe + +- **WHEN** a config file contains malformed JSONC +- **THEN** a warning is emitted naming the file, `restructure` is treated as disabled, and the core chain-guard plugin behavior is unchanged + +### Requirement: Restructure schema and defaults + +The `restructure` section SHALL have nested fields: `enabled` (boolean, default `false`), `max_segments` (positive integer, default `3`), `max_depth` (positive integer, default `2`). When `enabled` is `false` or the section is absent, plugin behavior SHALL be identical to before this change. Invalid `max_segments`/`max_depth` values (non-numeric or < 1) SHALL be dropped with a warning and the default SHALL apply. + +#### Scenario: Disabled by default + +- **WHEN** the config file exists but has no `restructure` section, or `restructure.enabled` is `false` +- **THEN** no complexity rejection occurs for any command + +#### Scenario: Enabled with defaults + +- **WHEN** the config contains `"restructure": { "enabled": true }` +- **THEN** thresholds are `max_segments: 3` and `max_depth: 2` + +#### Scenario: Explicit thresholds honored + +- **WHEN** the config contains `"restructure": { "enabled": true, "max_segments": 5, "max_depth": 1 }` +- **THEN** the thresholds are `5` segments and depth `1` + +#### Scenario: Invalid threshold values dropped + +- **WHEN** the config contains `"restructure": { "enabled": true, "max_segments": 0, "max_depth": "many" }` +- **THEN** both invalid values are dropped with warnings and defaults (`3`, `2`) apply + +### Requirement: Detect command complexity + +The system SHALL compute, per command, the segment count (existing `parseChain` output, including meta-command bodies) and the maximum substitution nesting depth reached while walking `$()`, backticks, and meta-command string arguments. Limits are exceeded when a metric is strictly greater than its threshold. The segment-count limit SHALL apply per line: a single-line command is checked as one line; a multi-line command SHALL have every line checked individually against the limit. The depth limit SHALL apply to the whole command in every shape. + +#### Scenario: Single-line chain over segment threshold + +- **WHEN** command is `a && b && c && d` (4 segments, no newline), `max_segments: 3` +- **THEN** limits are exceeded + +#### Scenario: Single-line chain at threshold — not exceeded + +- **WHEN** command is `a && b && c` (3 segments, no newline), `max_segments: 3` +- **THEN** limits are not exceeded + +#### Scenario: Multi-line script with one command per line — passes + +- **WHEN** command is a 5-line script with one command per line, `max_segments: 3` +- **THEN** no line exceeds the segment limit (each line is a single segment) — limits are not exceeded, multi-line remains the compliant form + +#### Scenario: Multi-line script with long per-line chains — rejected + +- **WHEN** command is a 2-line script where each line is a 5-segment `&&`-chain, `max_segments: 3` +- **THEN** limits are exceeded and the rejection message names the offending line number and its segment count + +#### Scenario: Nesting depth over threshold in any form + +- **WHEN** command is `echo $(echo $(whoami))` (depth 3) or a multi-line script containing a depth-3 substitution, `max_depth: 2` +- **THEN** limits are exceeded in both cases + +#### Scenario: Meta-command body counts + +- **WHEN** command is `bash -c "a && b && c && d"` (4 segments after recursive parse), `max_segments: 3` +- **THEN** limits are exceeded + +#### Scenario: Simple command passes + +- **WHEN** command is `git status`, `max_segments: 3`, `max_depth: 2` +- **THEN** limits are not exceeded + +### Requirement: Complexity-check interpreter inline scripts + +When `restructure` is enabled, the system SHALL additionally compute, for segments invoking an interpreter with an inline script argument — `python`/`python3 -c`, `perl -e`, `node -e`/`--eval`, `ruby -e`, `php -r`, and heredoc-scripted interpreters — the statement count inside the script string (split on `;` and newlines). An inline-script statement count strictly greater than `max_segments` SHALL count as limits exceeded for that command. + +#### Scenario: Long inline python script rejected + +- **WHEN** command is `python3 -c "import os; os.system('a'); os.system('b'); os.system('c'); os.system('d')"` (5 statements) on an ask-resolving chain, `max_segments: 3` +- **THEN** limits are exceeded and the guidance names the interpreter inline script + +#### Scenario: Pretty-formatted inline script passes + +- **WHEN** the same program is re-issued with newline-separated statements inside the quoted script (one statement per line), each count within `max_segments` +- **THEN** limits are not exceeded — pretty formatting is the compliant form + +#### Scenario: Non-interpreter command unaffected + +- **WHEN** command is `python3 script.py --verbose` (no inline script argument) +- **THEN** the inline-script metric does not apply; only the usual segment/depth checks run + +### Requirement: Reject ask-resolving complex one-liners with actionable guidance + +When `restructure` is enabled AND limits are exceeded AND the chain resolves to `ask`, the plugin SHALL reject the command by throwing in `tool.execute.before` — nothing SHALL execute and no permission dialog SHALL appear for that call. The error message SHALL include the actual segment count and nesting depth — for multi-line commands, the worst offending line number and its segment count; for interpreter inline scripts, the interpreter and statement count — and SHALL instruct the model to re-issue the command as separate bash tool calls or as a multi-line script with one command per line. Chains resolving to `allow`, `deny`, or `null`, and parse-error fail-closed denies, SHALL follow existing flows unchanged. + +#### Scenario: Allowed complex chain passes through — allowed stays allowed + +- **WHEN** command `git status && git log && git diff && git show` exceeds `max_segments: 3` and all segments match `"git *": "allow"` +- **THEN** the chain resolves to `allow` and runs without rejection — no restructuring message + +#### Scenario: Complex ask chain rejected + +- **WHEN** command `git status && rm -rf /tmp/x && echo ok && ls` exceeds `max_segments: 3` and `rm` resolves to `ask` +- **THEN** the plugin throws; the command does not execute; no permission dialog appears for this call; the error text contains "4" and the re-issue instruction + +#### Scenario: Deny flow unchanged + +- **WHEN** command `git push --force && git status && git log && git show` exceeds limits but `git push` resolves to `deny` +- **THEN** the existing deny flow applies (wrap + stored deny) — no restructuring message + +#### Scenario: No-opinion chain unchanged + +- **WHEN** command `a && b && c && d` exceeds limits and no bash rule matches any segment (chain action `null`) +- **THEN** the plugin does not throw — the command proceeds to native opencode handling + +#### Scenario: Parse error unchanged + +- **WHEN** command is `echo "unbalanced` (parse error) regardless of complexity +- **THEN** the fail-closed deny applies — no restructuring message + +#### Scenario: Repeated violation — same rejection + +- **WHEN** the model re-issues a complex single-line chain after a rejection +- **THEN** the same rejection fires again with updated counts (no counter, no escalation) + +#### Scenario: Feature disabled — ask flow unchanged + +- **WHEN** `restructure.enabled` is `false` and a complex ask-resolving chain arrives +- **THEN** the existing ask flow applies (wrap, native dialog) — no rejection + +### Requirement: Restructured commands remain fully verified + +Commands re-issued in a compliant form SHALL go through the normal permission evaluation with no bypass: separate tool calls arrive as single-segment commands; a multi-line script is parsed into per-line segments (newlines are segment separators). + +#### Scenario: Multi-line re-issue checked per line + +- **WHEN** the rejected `a && b && c && d` is re-issued as a 4-line script +- **THEN** `parseChain` yields 4 segments, each evaluated independently against permission rules; allowed lines pass, uncovered lines ask + +#### Scenario: Separate calls checked individually + +- **WHEN** the model re-issues as four single-command tool calls +- **THEN** each call is a single segment, evaluated independently; allowed ones run + +### Requirement: Documentation reflects actual chain behavior and JSONC config + +The README SHALL describe the implemented semantics: fully-allowed chains pass through without interruption; the "multi-segment chains trigger ask (defense-in-depth)" claim SHALL be removed or corrected. The README SHALL document the `opencode-bash-guard.jsonc` file (locations, `restructure` schema, defaults) and include a recommended AGENTS.md snippet as a complementary soft layer. + +#### Scenario: README example table corrected + +- **WHEN** the README documents `git status && git log` with both segments allowed +- **THEN** it states the chain passes through (not "ask") + +#### Scenario: Config file documented + +- **WHEN** the README "Readable commands" section is read +- **THEN** it shows the `opencode-bash-guard.jsonc` example with `restructure` nested fields, both config locations, and an AGENTS.md instruction snippet diff --git a/openspec/changes/chain-restructuring/tasks.md b/openspec/changes/chain-restructuring/tasks.md new file mode 100644 index 0000000..13be477 --- /dev/null +++ b/openspec/changes/chain-restructuring/tasks.md @@ -0,0 +1,53 @@ +## 1. Plugin Config Loader (`src/plugin-config.ts`, new) + +- [ ] 1.1 Add `jsonc-parser` dependency +- [ ] 1.2 Implement file discovery: global (`~/.config/opencode/opencode-bash-guard.jsonc`, honoring `XDG_CONFIG_HOME`) and project (`/.opencode/opencode-bash-guard.jsonc`, root from plugin init `input.directory`) +- [ ] 1.3 Parse JSONC (comments, trailing commas); missing file → skip silently; invalid JSONC → warning naming the file, feature treated as disabled +- [ ] 1.4 Deep-merge global + project (project wins); implement `parsePluginConfig(files): PluginConfig` +- [ ] 1.5 Define `RestructureConfig { enabled: boolean; maxSegments: number; maxDepth: number }` — defaults `enabled: false`, `maxSegments: 3`, `maxDepth: 2`; non-numeric or `< 1` threshold values dropped with warning, default applies +- [ ] 1.6 Wire loader into plugin init in `src/index.ts` +- [ ] 1.7 Unit tests: no files → disabled; project-over-global merge; JSONC comments/trailing commas; invalid file → warning + disabled; invalid thresholds → defaults; explicit values honored + +## 2. Chain Metrics (`src/chain.ts`) + +- [ ] 2.1 Extend `parseChain` to report max substitution nesting depth: track depth while recursively walking `$()`, backticks, and meta-command string args +- [ ] 2.2 Return shape: `{ segments, maxDepth, parseError }` (or parallel accessor) — keep existing call sites compiling +- [ ] 2.3 Unit tests: flat chain depth 0/1, single-level `$()`, triple nesting, `bash -c "..."` depth accounting +- [ ] 2.4 Per-line segmentation helper: split command into lines, parse each line into segments (returns per-line counts + worst offending line) +- [ ] 2.5 Interpreter inline-script detection: `python`/`python3 -c`, `perl -e`, `node -e`/`--eval`, `ruby -e`, `php -r`, heredoc-scripted interpreters — extract script string, count statements (`;`/newline split); unit tests: short script passes, `;`-split count, newline-split count, non-interpreter command unaffected + +## 3. Enforcement (`src/enforce.ts`) + +- [ ] 3.1 Export rejection-message builder with actual counts: single-line → `[opencode-bash-guard] Complex one-liner rejected (N chained commands, nesting depth D). Re-issue as separate bash tool calls, or as a multi-line script with one command per line — each command is then permission-checked individually.`; multi-line → worst offending line (`line K: N chained commands`); interpreter inline script → interpreter name + statement count with one-statement-per-line / move-to-file guidance +- [ ] 3.2 Implement complexity check: every line of the command → line segments > maxSegments (single-line = one line); whole command → maxDepth > maxDepthLimit; inline-script statement count > maxSegments counts as exceeded +- [ ] 3.3 In `beforeExecute`: after `resolveChain`, throw the guidance Error ONLY when action is `ask` AND `restructure.enabled` AND limits exceeded; `allow`/`deny`/`null`/parse-error flows verbatim +- [ ] 3.4 Unit tests: allowed complex chain passes (no throw); complex ask chain throws (message contains counts + instruction); multi-line one-command-per-line passes; 2-line script of 5-segment `&&` chains throws naming the line; inline `python3 -c` with 5 statements throws; pretty inline script passes; deny/ask-disabled/null/parse-error unchanged; repeated violation re-throws; boundaries (N == max passes, N+1 throws) + +## 4. Integration Tests + +- [ ] 4.1 Multi-line re-issue: 4-line script parses into 4 segments, each checked independently +- [ ] 4.2 Separate-calls re-issue: single-segment commands evaluate normally +- [ ] 4.3 Config matrix: disabled → zero throws across all commands; enabled with defaults; enabled with custom thresholds +- [ ] 4.4 Multi-line per-line chains: 2-line script of long `&&` chains on an ask-resolving chain → rejected with line-scoped message + +## 5. Documentation + +- [ ] 5.1 Fix README stale claims: `git status && git log` row → "passes through"; remove/correct "multi-segment chains trigger ask (defense-in-depth)" +- [ ] 5.2 README new section "Readable commands": `opencode-bash-guard.jsonc` example (both locations, `restructure` nested fields with defaults), strict-greater threshold semantics, what a rejection looks like (tool error, nothing executes, no dialog), AGENTS.md snippet: + + ```markdown + ## Bash command style + - Issue one command per tool call. For sequences, use separate bash calls. + - Never write chained one-liners (`a && b && c`). If rejected, split and retry. + ``` + +- [ ] 5.3 README known limitations: config read once at startup (restart to apply); heuristic inline-script statement counting (strings containing `;` can miscount; errs toward rejection); no retry counter; note on `permission.ask` reliability (issue anomalyco/opencode#19469) pending separate verification + +## 6. Verification + +- [ ] 6.1 `npm test` — full suite green including new tests +- [ ] 6.2 `npm run build` — type-check passes +- [ ] 6.3 Manual: complex not-allowed one-liner with `restructure.enabled: true` → tool error with counts, model retries as multi-line → readable ask dialog +- [ ] 6.4 Manual: complex fully-allowed one-liner with feature enabled → runs (allowed stays allowed) +- [ ] 6.5 Manual: no config file → behavior identical to previous release +- [ ] 6.6 Manual: deny/ask commands with feature disabled → unchanged