mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
feat: vendor permission system source
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
---
|
||||
issue: 249
|
||||
issue_title: "Bash external-directory gate ignores config-level allow rules for /tmp/* paths"
|
||||
---
|
||||
|
||||
# Fix bash external-directory gate config-level allow bypass
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The bash external-directory gate in `bash-external-directory.ts` ignores config-level `"allow"` rules when filtering uncovered paths.
|
||||
A config like `"/tmp/*": "allow"` should suppress the permission prompt for bash commands referencing `/tmp/` paths, but instead the prompt fires every time.
|
||||
This is especially disruptive for subagents that run tools writing to `/tmp/`.
|
||||
|
||||
## Goals
|
||||
|
||||
- Config-level `"allow"` rules for `external_directory` patterns suppress the prompt, matching behavior of `read`/`write` tool calls to the same paths.
|
||||
- Config-level `"deny"` rules for `external_directory` patterns produce a deny outcome (not a downgraded "ask").
|
||||
- Consistent with how `path.ts` and `bash-path.ts` handle config-level allow/deny.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Changing `bash-path.ts` — it already uses `check.state` for filtering and is not affected.
|
||||
- Changing the `path.ts` gate — it already returns `null` on `state === "allow"`.
|
||||
- Refactoring the bypass log event names (cosmetic; defer).
|
||||
|
||||
## Background
|
||||
|
||||
### Root cause
|
||||
|
||||
The `uncoveredPaths` filter in `describeBashExternalDirectoryGate` checks `source !== "session"`:
|
||||
|
||||
```typescript
|
||||
const uncoveredPaths = externalPaths.filter(
|
||||
(p) =>
|
||||
checkPermission("external_directory", { path: p }, agentName, bashSessionRules)
|
||||
.source !== "session",
|
||||
);
|
||||
```
|
||||
|
||||
For `external_directory`, `deriveSource()` in `permission-manager.ts` always returns `"special"` for non-session rules (because the surface is in `SPECIAL_PERMISSION_KEYS`).
|
||||
A config rule like `"/tmp/*": "allow"` produces `{ state: "allow", source: "special" }`, which fails the `source !== "session"` check and is treated as uncovered.
|
||||
|
||||
### Secondary issue
|
||||
|
||||
The path-less `extCheck` call (`checkPermission("external_directory", {}, agentName)`) always evaluates against the `"*"` catch-all.
|
||||
When uncovered paths include a `"deny"` pattern, the descriptor's `preCheck` reports `"ask"` (from the catch-all) instead of `"deny"`, downgrading the restriction.
|
||||
|
||||
### Sibling gate comparison
|
||||
|
||||
- `path.ts`: checks `check.state === "allow"` to bypass — correct.
|
||||
- `bash-path.ts`: uses `check.state` for deny/ask tracking and `source` only for bypass log semantics — correct.
|
||||
- `bash-external-directory.ts`: uses `source` for filtering — **buggy**.
|
||||
|
||||
## Design Overview
|
||||
|
||||
### Fix 1 — Filter by state, not source
|
||||
|
||||
Change the `uncoveredPaths` filter from `source !== "session"` to `state !== "allow"`.
|
||||
Any path that resolves to `"allow"` — whether from config, session, or any other source — is excluded from the uncovered set.
|
||||
|
||||
```typescript
|
||||
const uncoveredPaths = externalPaths.filter((p) => {
|
||||
const result = checkPermission(
|
||||
"external_directory",
|
||||
{ path: p },
|
||||
tcc.agentName ?? undefined,
|
||||
bashSessionRules,
|
||||
);
|
||||
return result.state !== "allow";
|
||||
});
|
||||
```
|
||||
|
||||
### Fix 2 — Use worst uncovered path for preCheck
|
||||
|
||||
Replace the path-less `extCheck` with the most restrictive check among uncovered paths.
|
||||
This ensures `deny` rules produce a deny descriptor, not a downgraded `"ask"`.
|
||||
|
||||
Reuse check results from the filter to avoid double evaluation:
|
||||
|
||||
```typescript
|
||||
const uncoveredEntries: Array<{ path: string; check: PermissionCheckResult }> = [];
|
||||
for (const p of externalPaths) {
|
||||
const check = checkPermission(
|
||||
"external_directory",
|
||||
{ path: p },
|
||||
tcc.agentName ?? undefined,
|
||||
bashSessionRules,
|
||||
);
|
||||
if (check.state !== "allow") {
|
||||
uncoveredEntries.push({ path: p, check });
|
||||
}
|
||||
}
|
||||
|
||||
if (uncoveredEntries.length === 0) {
|
||||
// All paths allowed — bypass
|
||||
return { action: "allow", log: { ... } };
|
||||
}
|
||||
|
||||
const uncoveredPaths = uncoveredEntries.map(({ path }) => path);
|
||||
|
||||
// Most restrictive check: deny > ask
|
||||
const worstCheck = uncoveredEntries.reduce((worst, { check }) => {
|
||||
if (check.state === "deny") return check;
|
||||
if (worst.state === "deny") return worst;
|
||||
return worst; // both "ask" — keep first
|
||||
}, uncoveredEntries[0].check);
|
||||
```
|
||||
|
||||
The descriptor uses `worstCheck` as `preCheck` instead of the old `extCheck`.
|
||||
|
||||
## Module-Level Changes
|
||||
|
||||
### `src/handlers/gates/bash-external-directory.ts`
|
||||
|
||||
1. Replace the `uncoveredPaths` filter with a loop that collects `{ path, check }` entries where `check.state !== "allow"`.
|
||||
2. Remove the path-less `extCheck` call.
|
||||
3. Compute `worstCheck` from the collected entries (deny > ask).
|
||||
4. Use `worstCheck` as the descriptor's `preCheck`.
|
||||
5. Remove the `PermissionCheckResult` import if it was only needed for the old `extCheck` type — verify.
|
||||
|
||||
### `test/handlers/gates/bash-external-directory.test.ts`
|
||||
|
||||
1. Update "uses config-level checkPermission for the policy state" — this test asserts the buggy behavior (config-level `allow` paths remain uncovered).
|
||||
After the fix, config-level `allow` causes a bypass.
|
||||
Rename and rewrite to verify config-level `allow` produces a `GateBypass`.
|
||||
2. Add a new test: config-level `deny` for a specific path produces a `GateDescriptor` with `preCheck.state === "deny"`.
|
||||
3. Add a new test: mixed paths — one config-allowed, one config-ask — produces a descriptor with only the ask path.
|
||||
4. Add a new test: mixed paths — one config-denied, one config-ask — produces a descriptor with `preCheck.state === "deny"` (worst wins).
|
||||
5. Verify "only includes uncovered paths when some are session-covered" still passes (it should — session `allow` is still filtered out by `state !== "allow"`).
|
||||
|
||||
## Test Impact Analysis
|
||||
|
||||
1. The filter fix enables testing config-level policy integration directly on the descriptor factory, which was previously impossible because all non-session sources were treated identically.
|
||||
2. The test "uses config-level checkPermission for the policy state" becomes redundant since its scenario (config allow → still uncovered) is the bug.
|
||||
It is replaced by a config-allow bypass test.
|
||||
3. All other existing tests remain valid — they exercise session-approved bypass, descriptor structure, and denial context, none of which change.
|
||||
|
||||
## TDD Order
|
||||
|
||||
1. **Red → Green:** Update the test "uses config-level checkPermission for the policy state" to expect a `GateBypass` when all paths are config-allowed.
|
||||
Add a test for config-level `deny` producing a `GateDescriptor` with `preCheck.state === "deny"`.
|
||||
Implement the filter + worst-check changes in `bash-external-directory.ts`.
|
||||
Commit: `fix: respect config-level allow/deny in bash external-directory gate (#249)`
|
||||
|
||||
2. **Green → Green:** Add tests for mixed-state paths (config-allow + config-ask, config-deny + config-ask).
|
||||
Verify they pass with the implementation from step 1.
|
||||
Commit: `test: add mixed-state path tests for bash external-directory gate (#249)`
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
1. **Bypass log says "session_approved" for config-allowed paths.**
|
||||
The bypass `GateBypass` log event uses `"permission_request.session_approved"` and `resolution: "session_approved"`.
|
||||
After the fix, config-allowed paths also produce this bypass.
|
||||
The log text is slightly misleading but functionally harmless — the gate correctly allows access.
|
||||
A follow-up could introduce a distinct event, but it is not blocking.
|
||||
Mitigated: documented as non-goal.
|
||||
|
||||
2. **Double `checkPermission` call eliminated.**
|
||||
The old code called `checkPermission` once per path in the filter, then once more (path-less) for `extCheck`.
|
||||
The new code calls once per path and reuses the result.
|
||||
This is strictly fewer calls — no regression risk.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None — the fix direction is specified in the issue and consistent with sibling gates.
|
||||
Reference in New Issue
Block a user