From 0dcab7da9a5b649298e5799336bab35feb33a3e4 Mon Sep 17 00:00:00 2001 From: Ildar Valiullin Date: Thu, 3 Sep 2026 14:14:40 +0300 Subject: [PATCH 1/5] docs(openspec): propose chain-restructuring change Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../chain-restructuring/.openspec.yaml | 2 ++ .../changes/chain-restructuring/proposal.md | 30 +++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 openspec/changes/chain-restructuring/.openspec.yaml create mode 100644 openspec/changes/chain-restructuring/proposal.md 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/proposal.md b/openspec/changes/chain-restructuring/proposal.md new file mode 100644 index 0000000..d2dcfe4 --- /dev/null +++ b/openspec/changes/chain-restructuring/proposal.md @@ -0,0 +1,30 @@ +## Why + +Complex one-liners (`a && b && c`, nested `$()`, `eval`/`sh -c` wrappers) are hard for humans to review, and today they pass **silently** when every segment matches an allow rule — the agent has no incentive to write readable commands, and no mechanism tells it to restructure. Prompt-level guidance (AGENTS.md) is a soft constraint with no verification loop. The right fix is a deterministic one: reject the command with an instructive error so the agent re-issues it in a reviewable form. + +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 + +- **Complexity detection** — segment count and substitution nesting depth, computed from the existing `unbash`-based chain parse; meta-commands (`eval`, `sh -c`, `bash -c`, `zsh -c`) already count as segments +- **Reject-with-guidance** — when limits are exceeded, the plugin throws in `tool.execute.before`; nothing executes, and the error text tells the model exactly how to comply: re-issue as separate bash tool calls or a multi-line script with one command per line (each line is then parsed and permission-checked individually) +- **New config section `permission.bash_restructure`** — `{ "max_segments": 3, "max_depth": 2 }`; presence enables the feature, absent means zero behavior change; values validated, defaults applied for missing fields +- **Scope: silent-pass chains only** — restructuring fires when the chain would run without interruption (no opinion or fully allowed); `deny`/`ask`/parse-error flows are unchanged (deny means "don't do it at all"; ask already surfaces the command to a human) +- **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 actual behavior +- **AGENTS.md guidance** — README snippet users can add for a soft layer that reduces rejection frequency + +## Capabilities + +### New Capabilities +- `chain-restructuring`: Detect overly complex one-liner bash commands 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 + +- Config: optional new `permission.bash_restructure` object in `opencode.json` — absent section means zero behavior change +- Code: `src/chain.ts` (expose nesting depth), `src/config.ts` (parse new section), `src/enforce.ts` (reject path in `beforeExecute`) +- Docs: README new section + behavior correction + AGENTS.md snippet +- No new dependencies From d169473c42918246ac4128feea76dc0e78b02bb9 Mon Sep 17 00:00:00 2001 From: Ildar Valiullin Date: Thu, 3 Sep 2026 14:14:40 +0300 Subject: [PATCH 2/5] docs(openspec): add design for chain restructuring Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changes/chain-restructuring/design.md | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 openspec/changes/chain-restructuring/design.md diff --git a/openspec/changes/chain-restructuring/design.md b/openspec/changes/chain-restructuring/design.md new file mode 100644 index 0000000..1064629 --- /dev/null +++ b/openspec/changes/chain-restructuring/design.md @@ -0,0 +1,93 @@ +## 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"). The README and the base design's Goals bullet still claim "multi-segment chains trigger ask (defense-in-depth)" — that was dropped during implementation as too noisy (every `cd x && y` would prompt). Result: a fully-allowed messy one-liner runs with zero human visibility. + +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. + +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 with an error message that teaches the model the compliant form +- 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 +- Opt-in config with zero behavior change when absent +- 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 `ask`/`deny` flows in v1 (deny = "don't do this at all"; ask already shows the command to a human) +- 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 + +## Decisions + +1. **Config shape: `permission.bash_restructure`** + + ```json + { + "permission": { + "bash_restructure": { + "max_segments": 3, + "max_depth": 2 + } + } + } + ``` + + Presence enables the feature; absence (or explicit `false`) keeps current behavior. Missing fields fall back to defaults (`max_segments: 3`, `max_depth: 2`). Non-positive or non-numeric values are dropped with a warning, matching the existing parser policy. Rejection fires when a metric **exceeds** its threshold (`git status && git log` = 2 segments passes a default config; 4+ segments is rejected). + +2. **Metrics: segment count + max substitution nesting depth** + + Segment count already exists (`parseChain`). Nesting depth must be newly exposed: `chain.ts` already walks the AST recursively into `$()`/backticks and meta-command string args — extend it to report the maximum depth reached. Meta-commands (`eval`, `sh -c`, `bash -c`, `zsh -c`) already count their parsed bodies as segments. Depth catches single-command obfuscation (`echo $(echo $(...))`) that segment count misses. + +3. **Enforcement point: after chain resolution, only for silent-pass outcomes** + + `beforeExecute` flow becomes: parse → resolve chain (existing) → **if action is `null` or `allow` and complexity exceeds limits → throw**. Rationale: + - `deny`: the message "you may not run this" is correct — restructuring won't help, and wrapping/deny semantics stay intact. + - `ask`: the human already sees the command and decides; injecting a rejection adds a retry loop without a safety gain (v1 scope note: extending restructuring to `ask` is a possible future option). + - `null`/`allow`: the command would run without any human review — exactly where an unreadable one-liner is most dangerous and where restructuring adds the most value. + - Parse errors: existing fail-closed deny happens before resolution and is unaffected. + +4. **Rejection message must be actionable** + + Thrown error text (single source of truth, exported constant): + + ``` + [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. + ``` + + The message contains the actual counts (so the model can self-correct) and both compliant forms. Throwing means the command never executes and no permission dialog appears — the model retries on its own. + +5. **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. + +6. **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. + - Add a "Readable commands" section documenting `bash_restructure` 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. + ``` + +7. **Repeated violations: same rejection every time** + + No attempt counter in v1. A model that keeps exceeding thresholds gets the same error; it either complies, gives up, or asks the user. Counter/escalation logic is future work if observed to be a problem. + +## Risks / Trade-offs + +- **[Retry loops burn tokens]** A stubborn model may retry complex commands repeatedly. Mitigation: actionable message with exact counts; thresholds generous by default; counters deferred until observed. +- **[False positives on legitimate pipelines]** `cat a | grep b | wc -l` (3 segments) passes defaults; 4-stage pipelines get rejected. Mitigation: thresholds are user-configurable; document raising `max_segments` for pipeline-heavy workflows. +- **[Throw bypasses permission dialogs for rejected commands]** Intended: nothing executes, so nothing needs permission. But a user expecting a dialog sees only a tool error — documented in README. +- **[Depth exposure requires chain.ts changes]** Recursive walk already exists; the change is additive (return max depth). Low risk, covered by tests. +- **[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. From 6962bad907c68d7e0c75b6bfb195ec674ddcb438 Mon Sep 17 00:00:00 2001 From: Ildar Valiullin Date: Thu, 3 Sep 2026 14:14:40 +0300 Subject: [PATCH 3/5] docs(openspec): add chain-restructuring spec and tasks Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../specs/chain-restructuring/spec.md | 116 ++++++++++++++++++ openspec/changes/chain-restructuring/tasks.md | 45 +++++++ 2 files changed, 161 insertions(+) create mode 100644 openspec/changes/chain-restructuring/specs/chain-restructuring/spec.md create mode 100644 openspec/changes/chain-restructuring/tasks.md 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..c8978b8 --- /dev/null +++ b/openspec/changes/chain-restructuring/specs/chain-restructuring/spec.md @@ -0,0 +1,116 @@ +## ADDED Requirements + +### Requirement: Parse bash_restructure config + +The system SHALL parse an optional `permission.bash_restructure` object from the merged opencode config. Presence of the object (not `false`) SHALL enable complexity rejection. Missing fields SHALL fall back to defaults (`max_segments: 3`, `max_depth: 2`). Non-positive or non-numeric field values SHALL be dropped with a warning. When the section is absent or `false`, plugin behavior SHALL be identical to before this change. + +#### Scenario: Section absent — no change + +- **WHEN** `permission.bash_restructure` is not present in the config +- **THEN** complexity rejection is disabled and no behavior changes vs. the previous release + +#### Scenario: Section present — defaults applied + +- **WHEN** `permission.bash_restructure` is `{}` (empty object) +- **THEN** rejection is enabled with `max_segments: 3` and `max_depth: 2` + +#### Scenario: Explicit thresholds honored + +- **WHEN** `permission.bash_restructure` is `{ "max_segments": 5, "max_depth": 1 }` +- **THEN** the thresholds are `5` segments and depth `1` + +#### Scenario: Invalid values dropped + +- **WHEN** `permission.bash_restructure` is `{ "max_segments": 0, "max_depth": "many" }` +- **THEN** both invalid values are dropped with warnings and defaults 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. Complexity limits are exceeded when either metric is strictly greater than its threshold. + +#### Scenario: Segment count over threshold + +- **WHEN** command is `a && b && c && d` (4 segments), `max_segments: 3` +- **THEN** limits are exceeded + +#### Scenario: Segment count at threshold — not exceeded + +- **WHEN** command is `a && b && c` (3 segments), `max_segments: 3` +- **THEN** limits are not exceeded + +#### Scenario: Nesting depth over threshold + +- **WHEN** command is `echo $(echo $(whoami))` (depth 3), `max_depth: 2`, single segment +- **THEN** limits are exceeded + +#### 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: Reject with actionable guidance via thrown error + +When complexity limits are exceeded AND the chain action resolves to `null` or `allow`, the plugin SHALL reject the command by throwing in `tool.execute.before` — nothing SHALL execute and no permission dialog SHALL appear. The error message SHALL include the actual segment count and nesting depth 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. Commands resolving to `deny` or `ask`, and parse-error fail-closed denies, SHALL follow existing flows unchanged. + +#### Scenario: Fully-allowed complex chain rejected + +- **WHEN** command `git status && git log && git diff && git show` exceeds `max_segments: 3`, all segments match `"git *": "allow"` +- **THEN** the plugin throws; the command does not execute; the error text contains "4" and the re-issue instruction + +#### Scenario: Uncovered complex chain rejected + +- **WHEN** command `a && b && c && d` exceeds limits and no bash rule matches any segment (chain action `null`) +- **THEN** the plugin throws with the guidance message + +#### 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: Ask flow unchanged + +- **WHEN** command `git status && rm -rf /tmp/x && echo ok && ls` exceeds limits and `rm` resolves to `ask` +- **THEN** the existing ask flow applies (wrap, native dialog) — no restructuring message + +#### 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 command after a rejection +- **THEN** the same rejection fires again with updated counts (no counter, no escalation) + +### 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` (all allowed) is re-issued as a 4-line script +- **THEN** `parseChain` yields 4 segments, each evaluated independently against permission rules + +#### 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 + +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 `permission.bash_restructure` 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: AGENTS.md snippet present + +- **WHEN** the README "Readable commands" section is read +- **THEN** it contains the `bash_restructure` config example 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..eb2aff4 --- /dev/null +++ b/openspec/changes/chain-restructuring/tasks.md @@ -0,0 +1,45 @@ +## 1. Chain Metrics (`src/chain.ts`) + +- [ ] 1.1 Extend `parseChain` to report max substitution nesting depth: track depth while recursively walking `$()`, backticks, and meta-command string args +- [ ] 1.2 Return shape: `{ segments, maxDepth, parseError }` (or parallel accessor) — keep existing call sites compiling +- [ ] 1.3 Unit tests: flat chain depth 0/1, single-level `$()`, triple nesting, `bash -c "..."` depth accounting + +## 2. Config (`src/config.ts`) + +- [ ] 2.1 Add `RestructureConfig { enabled: boolean; maxSegments: number; maxDepth: number }` to `PluginConfig` +- [ ] 2.2 Parse `permission.bash_restructure`: absent or `false` → disabled; object → enabled, defaults `maxSegments: 3`, `maxDepth: 2`; non-positive/non-numeric values dropped with warning +- [ ] 2.3 Unit tests for all config scenarios in `specs/chain-restructuring/spec.md` + +## 3. Enforcement (`src/enforce.ts`) + +- [ ] 3.1 Export rejection-message builder with actual counts: `[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.` +- [ ] 3.2 In `beforeExecute`: after `resolveChain`, if action is `null`/`allow` AND (segments > maxSegments || maxDepth > maxDepth limit) → throw Error with the message +- [ ] 3.3 Preserve flows: parse-error deny before resolution; `deny` → wrap+store; `ask` → wrap+store; disabled config → no throw anywhere +- [ ] 3.4 Unit tests: rejected allow-chain (nothing executes, message has counts), rejected null-chain, deny/ask unchanged, parse error unchanged, repeated violation re-throws, threshold boundary (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; default thresholds; custom thresholds + +## 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)" in How-it-works and design references +- [ ] 5.2 README new section "Readable commands": `bash_restructure` config example, threshold semantics (strictly greater), what a rejection looks like (tool error, nothing executes), 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: pipeline-heavy workflows may need higher `max_segments`; no retry counter in v1; 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 allowed one-liner with `bash_restructure` present → tool error with counts, model retries split +- [ ] 6.4 Manual: same command without the section → runs as before +- [ ] 6.5 Manual: deny/ask commands unchanged with feature enabled From 161c254fb26d0451404ce5e7d9da19d243b51501 Mon Sep 17 00:00:00 2001 From: Ildar Valiullin Date: Fri, 4 Sep 2026 14:30:31 +0300 Subject: [PATCH 4/5] docs(openspec): move restructure config to opencode-bash-guard.jsonc Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../changes/chain-restructuring/design.md | 82 ++++++++++++------- .../changes/chain-restructuring/proposal.md | 24 +++--- 2 files changed, 65 insertions(+), 41 deletions(-) diff --git a/openspec/changes/chain-restructuring/design.md b/openspec/changes/chain-restructuring/design.md index 1064629..c3e845f 100644 --- a/openspec/changes/chain-restructuring/design.md +++ b/openspec/changes/chain-restructuring/design.md @@ -1,9 +1,11 @@ ## 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"). The README and the base design's Goals bullet still claim "multi-segment chains trigger ask (defense-in-depth)" — that was dropped during implementation as too noisy (every `cd x && y` would prompt). Result: a fully-allowed messy one-liner runs with zero human visibility. +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. @@ -13,47 +15,65 @@ API facts verified against `@opencode-ai/plugin@1.18.6` types and the opencode m ## Goals / Non-Goals **Goals:** -- Deterministically reject unreadable one-liners with an error message that teaches the model the compliant form +- 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 -- Opt-in config with zero behavior change when absent +- 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 `ask`/`deny` flows in v1 (deny = "don't do this at all"; ask already shows the command to a human) +- 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) ## Decisions -1. **Config shape: `permission.bash_restructure`** +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. - ```json +2. **Schema: `restructure` with nested fields, disabled by default** + + ```jsonc { - "permission": { - "bash_restructure": { - "max_segments": 3, - "max_depth": 2 - } + // 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 } } ``` - Presence enables the feature; absence (or explicit `false`) keeps current behavior. Missing fields fall back to defaults (`max_segments: 3`, `max_depth: 2`). Non-positive or non-numeric values are dropped with a warning, matching the existing parser policy. Rejection fires when a metric **exceeds** its threshold (`git status && git log` = 2 segments passes a default config; 4+ segments is rejected). + - `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. -2. **Metrics: segment count + max substitution nesting depth** + 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. - Segment count already exists (`parseChain`). Nesting depth must be newly exposed: `chain.ts` already walks the AST recursively into `$()`/backticks and meta-command string args — extend it to report the maximum depth reached. Meta-commands (`eval`, `sh -c`, `bash -c`, `zsh -c`) already count their parsed bodies as segments. Depth catches single-command obfuscation (`echo $(echo $(...))`) that segment count misses. +4. **One-liner targeting: segment limit applies to single-line commands only** -3. **Enforcement point: after chain resolution, only for silent-pass outcomes** + - Command contains no newline → both `max_segments` and `max_depth` checks apply. + - Command contains a newline (multi-line script) → **exempt from the segment limit**; depth check still applies. - `beforeExecute` flow becomes: parse → resolve chain (existing) → **if action is `null` or `allow` and complexity exceeds limits → throw**. Rationale: - - `deny`: the message "you may not run this" is correct — restructuring won't help, and wrapping/deny semantics stay intact. - - `ask`: the human already sees the command and decides; injecting a rejection adds a retry loop without a safety gain (v1 scope note: extending restructuring to `ask` is a possible future option). - - `null`/`allow`: the command would run without any human review — exactly where an unreadable one-liner is most dangerous and where restructuring adds the most value. - - Parse errors: existing fail-closed deny happens before resolution and is unaffected. + Rationale: the compliant form ("multi-line, one command per line") must never violate the segment limit, otherwise the retry loop can deadlock (a 5-step task re-issued as 5 lines would still exceed `max_segments: 3` and be rejected forever). Multi-line IS the readable form the feature asks for. Deep nesting remains unreadable in any shape, so `max_depth` applies to both forms. Known edge, accepted for v1: a multi-line script with long `&&`-chains inside individual lines passes the segment check (ask still gates uncovered segments; a per-line segment check is possible future refinement). -4. **Rejection message must be actionable** +5. **Rejection message must be actionable** Thrown error text (single source of truth, exported constant): @@ -65,14 +85,14 @@ API facts verified against `@opencode-ai/plugin@1.18.6` types and the opencode m The message contains the actual counts (so the model can self-correct) and both compliant forms. Throwing means the command never executes and no permission dialog appears — the model retries on its own. -5. **Restructured output is still fully verified** +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. -6. **README correction + AGENTS.md snippet** +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. - - Add a "Readable commands" section documenting `bash_restructure` and a recommended AGENTS.md snippet (soft layer that reduces rejection frequency): + - 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 @@ -80,14 +100,16 @@ API facts verified against `@opencode-ai/plugin@1.18.6` types and the opencode m - Never write chained one-liners (`a && b && c`). If rejected, split and retry. ``` -7. **Repeated violations: same rejection every time** +8. **Repeated violations: same rejection every time** - No attempt counter in v1. A model that keeps exceeding thresholds gets the same error; it either complies, gives up, or asks the user. Counter/escalation logic is future work if observed to be a problem. + No attempt counter in v1. A compliant re-issue always exists (multi-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. ## Risks / Trade-offs -- **[Retry loops burn tokens]** A stubborn model may retry complex commands repeatedly. Mitigation: actionable message with exact counts; thresholds generous by default; counters deferred until observed. -- **[False positives on legitimate pipelines]** `cat a | grep b | wc -l` (3 segments) passes defaults; 4-stage pipelines get rejected. Mitigation: thresholds are user-configurable; document raising `max_segments` for pipeline-heavy workflows. -- **[Throw bypasses permission dialogs for rejected commands]** Intended: nothing executes, so nothing needs permission. But a user expecting a dialog sees only a tool error — documented in README. -- **[Depth exposure requires chain.ts changes]** Recursive walk already exists; the change is additive (return max depth). Low risk, covered by tests. +- **[Retry loops burn tokens]** Mitigated structurally: a compliant multi-line form never violates `max_segments`, so the loop cannot deadlock. Depth-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. +- **[Multi-line scripts with per-line chains pass the segment check]** Accepted edge for v1 (see decision 4); ask still gates uncovered segments; per-line refinement possible later. - **[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 index d2dcfe4..a961c84 100644 --- a/openspec/changes/chain-restructuring/proposal.md +++ b/openspec/changes/chain-restructuring/proposal.md @@ -1,22 +1,24 @@ ## Why -Complex one-liners (`a && b && c`, nested `$()`, `eval`/`sh -c` wrappers) are hard for humans to review, and today they pass **silently** when every segment matches an allow rule — the agent has no incentive to write readable commands, and no mechanism tells it to restructure. Prompt-level guidance (AGENTS.md) is a soft constraint with no verification loop. The right fix is a deterministic one: reject the command with an instructive error so the agent re-issues it in a reviewable form. +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 -- **Complexity detection** — segment count and substitution nesting depth, computed from the existing `unbash`-based chain parse; meta-commands (`eval`, `sh -c`, `bash -c`, `zsh -c`) already count as segments -- **Reject-with-guidance** — when limits are exceeded, the plugin throws in `tool.execute.before`; nothing executes, and the error text tells the model exactly how to comply: re-issue as separate bash tool calls or a multi-line script with one command per line (each line is then parsed and permission-checked individually) -- **New config section `permission.bash_restructure`** — `{ "max_segments": 3, "max_depth": 2 }`; presence enables the feature, absent means zero behavior change; values validated, defaults applied for missing fields -- **Scope: silent-pass chains only** — restructuring fires when the chain would run without interruption (no opinion or fully allowed); `deny`/`ask`/parse-error flows are unchanged (deny means "don't do it at all"; ask already surfaces the command to a human) -- **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 actual behavior -- **AGENTS.md guidance** — README snippet users can add for a soft layer that reduces rejection frequency +- **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 +- **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 +- **One-liner targeting** — the segment limit applies to single-line commands; multi-line scripts are the compliant form and are exempt from the segment limit (still depth-checked), so a compliant re-issue always exists +- **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 and reject them with actionable guidance so the agent re-issues readable, individually-checkable commands +- `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 @@ -24,7 +26,7 @@ None (README behavioral correction is documentation, not a spec change; the base ## Impact -- Config: optional new `permission.bash_restructure` object in `opencode.json` — absent section means zero behavior change -- Code: `src/chain.ts` (expose nesting depth), `src/config.ts` (parse new section), `src/enforce.ts` (reject path in `beforeExecute`) +- 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 -- No new dependencies From 6e9f64852320d84caee1d16efa40330cc8dd08c3 Mon Sep 17 00:00:00 2001 From: Ildar Valiullin Date: Fri, 4 Sep 2026 14:30:31 +0300 Subject: [PATCH 5/5] docs(openspec): scope restructuring to ask chains in spec and tasks Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- .../specs/chain-restructuring/spec.md | 110 ++++++++++++------ openspec/changes/chain-restructuring/tasks.md | 41 ++++--- 2 files changed, 95 insertions(+), 56 deletions(-) diff --git a/openspec/changes/chain-restructuring/specs/chain-restructuring/spec.md b/openspec/changes/chain-restructuring/specs/chain-restructuring/spec.md index c8978b8..ab8753b 100644 --- a/openspec/changes/chain-restructuring/specs/chain-restructuring/spec.md +++ b/openspec/changes/chain-restructuring/specs/chain-restructuring/spec.md @@ -1,47 +1,76 @@ ## ADDED Requirements -### Requirement: Parse bash_restructure config +### Requirement: Load plugin config from opencode-bash-guard.jsonc -The system SHALL parse an optional `permission.bash_restructure` object from the merged opencode config. Presence of the object (not `false`) SHALL enable complexity rejection. Missing fields SHALL fall back to defaults (`max_segments: 3`, `max_depth: 2`). Non-positive or non-numeric field values SHALL be dropped with a warning. When the section is absent or `false`, plugin behavior SHALL be identical to before this change. +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: Section absent — no change +#### Scenario: No config file anywhere — feature off -- **WHEN** `permission.bash_restructure` is not present in the config -- **THEN** complexity rejection is disabled and no behavior changes vs. the previous release +- **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: Section present — defaults applied +#### Scenario: Project overrides global -- **WHEN** `permission.bash_restructure` is `{}` (empty object) -- **THEN** rejection is enabled with `max_segments: 3` and `max_depth: 2` +- **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** `permission.bash_restructure` is `{ "max_segments": 5, "max_depth": 1 }` +- **WHEN** the config contains `"restructure": { "enabled": true, "max_segments": 5, "max_depth": 1 }` - **THEN** the thresholds are `5` segments and depth `1` -#### Scenario: Invalid values dropped +#### Scenario: Invalid threshold values dropped -- **WHEN** `permission.bash_restructure` is `{ "max_segments": 0, "max_depth": "many" }` -- **THEN** both invalid values are dropped with warnings and defaults apply +- **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. Complexity limits are exceeded when either metric is strictly greater than its threshold. +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 to single-line commands only; a command containing a newline SHALL be exempt from the segment-count limit but still subject to the depth limit. -#### Scenario: Segment count over threshold +#### Scenario: Single-line chain over segment threshold -- **WHEN** command is `a && b && c && d` (4 segments), `max_segments: 3` +- **WHEN** command is `a && b && c && d` (4 segments, no newline), `max_segments: 3` - **THEN** limits are exceeded -#### Scenario: Segment count at threshold — not exceeded +#### Scenario: Single-line chain at threshold — not exceeded -- **WHEN** command is `a && b && c` (3 segments), `max_segments: 3` +- **WHEN** command is `a && b && c` (3 segments, no newline), `max_segments: 3` - **THEN** limits are not exceeded -#### Scenario: Nesting depth over threshold +#### Scenario: Multi-line script exempt from segment limit -- **WHEN** command is `echo $(echo $(whoami))` (depth 3), `max_depth: 2`, single segment -- **THEN** limits are exceeded +- **WHEN** command is a 5-line script with one command per line, `max_segments: 3` +- **THEN** the segment limit is not exceeded (multi-line is the compliant form) + +#### 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 @@ -53,29 +82,29 @@ The system SHALL compute, per command, the segment count (existing `parseChain` - **WHEN** command is `git status`, `max_segments: 3`, `max_depth: 2` - **THEN** limits are not exceeded -### Requirement: Reject with actionable guidance via thrown error +### Requirement: Reject ask-resolving complex one-liners with actionable guidance -When complexity limits are exceeded AND the chain action resolves to `null` or `allow`, the plugin SHALL reject the command by throwing in `tool.execute.before` — nothing SHALL execute and no permission dialog SHALL appear. The error message SHALL include the actual segment count and nesting depth 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. Commands resolving to `deny` or `ask`, and parse-error fail-closed denies, SHALL follow existing flows unchanged. +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 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: Fully-allowed complex chain rejected +#### Scenario: Allowed complex chain passes through — allowed stays allowed -- **WHEN** command `git status && git log && git diff && git show` exceeds `max_segments: 3`, all segments match `"git *": "allow"` -- **THEN** the plugin throws; the command does not execute; the error text contains "4" and the re-issue instruction +- **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: Uncovered complex chain rejected +#### Scenario: Complex ask chain rejected -- **WHEN** command `a && b && c && d` exceeds limits and no bash rule matches any segment (chain action `null`) -- **THEN** the plugin throws with the guidance message +- **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: Ask flow unchanged +#### Scenario: No-opinion chain unchanged -- **WHEN** command `git status && rm -rf /tmp/x && echo ok && ls` exceeds limits and `rm` resolves to `ask` -- **THEN** the existing ask flow applies (wrap, native dialog) — no restructuring message +- **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 @@ -84,33 +113,38 @@ When complexity limits are exceeded AND the chain action resolves to `null` or ` #### Scenario: Repeated violation — same rejection -- **WHEN** the model re-issues a complex command after a 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` (all allowed) is re-issued as a 4-line script -- **THEN** `parseChain` yields 4 segments, each evaluated independently against permission rules +- **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 +### 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 `permission.bash_restructure` and include a recommended AGENTS.md snippet as a complementary soft layer. +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: AGENTS.md snippet present +#### Scenario: Config file documented - **WHEN** the README "Readable commands" section is read -- **THEN** it contains the `bash_restructure` config example and an AGENTS.md instruction snippet +- **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 index eb2aff4..69a131e 100644 --- a/openspec/changes/chain-restructuring/tasks.md +++ b/openspec/changes/chain-restructuring/tasks.md @@ -1,32 +1,36 @@ -## 1. Chain Metrics (`src/chain.ts`) +## 1. Plugin Config Loader (`src/plugin-config.ts`, new) -- [ ] 1.1 Extend `parseChain` to report max substitution nesting depth: track depth while recursively walking `$()`, backticks, and meta-command string args -- [ ] 1.2 Return shape: `{ segments, maxDepth, parseError }` (or parallel accessor) — keep existing call sites compiling -- [ ] 1.3 Unit tests: flat chain depth 0/1, single-level `$()`, triple nesting, `bash -c "..."` depth accounting +- [ ] 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. Config (`src/config.ts`) +## 2. Chain Metrics (`src/chain.ts`) -- [ ] 2.1 Add `RestructureConfig { enabled: boolean; maxSegments: number; maxDepth: number }` to `PluginConfig` -- [ ] 2.2 Parse `permission.bash_restructure`: absent or `false` → disabled; object → enabled, defaults `maxSegments: 3`, `maxDepth: 2`; non-positive/non-numeric values dropped with warning -- [ ] 2.3 Unit tests for all config scenarios in `specs/chain-restructuring/spec.md` +- [ ] 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 ## 3. Enforcement (`src/enforce.ts`) - [ ] 3.1 Export rejection-message builder with actual counts: `[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.` -- [ ] 3.2 In `beforeExecute`: after `resolveChain`, if action is `null`/`allow` AND (segments > maxSegments || maxDepth > maxDepth limit) → throw Error with the message -- [ ] 3.3 Preserve flows: parse-error deny before resolution; `deny` → wrap+store; `ask` → wrap+store; disabled config → no throw anywhere -- [ ] 3.4 Unit tests: rejected allow-chain (nothing executes, message has counts), rejected null-chain, deny/ask unchanged, parse error unchanged, repeated violation re-throws, threshold boundary (N == max passes, N+1 throws) +- [ ] 3.2 Implement complexity check: single-line command (no newline) → segments > maxSegments; any command → maxDepth > maxDepthLimit; multi-line exempt from segment check +- [ ] 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); deny/ask-disabled/null/parse-error unchanged; repeated violation re-throws; boundaries (N == max passes, N+1 throws; multi-line with many lines passes segment check) ## 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; default thresholds; custom thresholds +- [ ] 4.3 Config matrix: disabled → zero throws across all commands; enabled with defaults; enabled with custom thresholds ## 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)" in How-it-works and design references -- [ ] 5.2 README new section "Readable commands": `bash_restructure` config example, threshold semantics (strictly greater), what a rejection looks like (tool error, nothing executes), AGENTS.md snippet: +- [ ] 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 @@ -34,12 +38,13 @@ - Never write chained one-liners (`a && b && c`). If rejected, split and retry. ``` -- [ ] 5.3 README known limitations: pipeline-heavy workflows may need higher `max_segments`; no retry counter in v1; note on `permission.ask` reliability (issue anomalyco/opencode#19469) pending separate verification +- [ ] 5.3 README known limitations: config read once at startup (restart to apply); multi-line scripts with long per-line `&&`-chains pass the segment check (v1 edge); 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 allowed one-liner with `bash_restructure` present → tool error with counts, model retries split -- [ ] 6.4 Manual: same command without the section → runs as before -- [ ] 6.5 Manual: deny/ask commands unchanged with feature enabled +- [ ] 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