mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
220 lines
12 KiB
Markdown
220 lines
12 KiB
Markdown
---
|
|
issue: 57
|
|
issue_title: "Replace SessionApprovalCache with session Ruleset"
|
|
---
|
|
|
|
# Replace SessionApprovalCache with session Ruleset
|
|
|
|
## Problem Statement
|
|
|
|
`SessionApprovalCache` is a standalone data structure with its own matching engine (directory-prefix matching via `isPathWithinDirectory()`), separate from the unified `Rule` / `Ruleset` / `evaluate()` system established by #55 and #56.
|
|
This duplication blocks #51 (generalize session approvals to all permission surfaces), because the prefix-based matcher only works for `external_directory`.
|
|
|
|
## Goals
|
|
|
|
- Replace `SessionApprovalCache` with a `SessionRules` class that wraps a plain `Ruleset`.
|
|
- Replace `deriveApprovalPrefix()` with `deriveApprovalPattern()` that returns a wildcard glob (`/path/to/dir/*`).
|
|
- Pass session rules into `evaluate()` as the highest-priority ruleset (appended after config rules).
|
|
- Move session-approval lookup out of the `tool_call` handler and into the unified `evaluate()` path.
|
|
- Preserve identical external_directory approval behavior (directory-scoped, session-ephemeral, cleared on shutdown).
|
|
|
|
## Non-Goals
|
|
|
|
- Generalizing session approvals to non-external_directory surfaces (#51 — follow-up).
|
|
- Changing the permission dialog options or adding pattern suggestions (#51).
|
|
- Persisting session approvals to disk.
|
|
- Changing the on-disk config format or `/permission-system` slash command name.
|
|
|
|
## Background
|
|
|
|
### Relevant modules
|
|
|
|
| File | Role |
|
|
| ------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
| `src/session-approval-cache.ts` | Current `SessionApprovalCache` class + `deriveApprovalPrefix()` |
|
|
| `src/rule.ts` | `Rule`, `Ruleset`, `evaluate()` — the unified permission engine |
|
|
| `src/wildcard-matcher.ts` | `wildcardMatch()` used by `evaluate()` |
|
|
| `src/normalize.ts` | Config → Ruleset normalization |
|
|
| `src/runtime.ts` | Creates `sessionApprovalCache` on the `ExtensionRuntime` |
|
|
| `src/handlers/tool-call.ts` | Consumes `sessionApprovalCache` in 5 places for external_directory gates |
|
|
| `src/handlers/lifecycle.ts` | Calls `sessionApprovalCache.clear()` on shutdown |
|
|
| `src/permission-manager.ts` | `resolvePermissions()` builds the config Ruleset; `checkPermission()` calls `evaluate()` |
|
|
|
|
### Permission surface
|
|
|
|
`external_directory` (special surface).
|
|
Session approvals currently only apply to this surface; the refactor preserves that scope.
|
|
|
|
### How session approvals are used today
|
|
|
|
1. **File-tool external_directory gate** — before prompting, `findMatchingPrefix("external_directory", normalizedPath)` checks if the path was previously approved.
|
|
If yes, logs `session_approved` and falls through.
|
|
If no, runs the normal `applyPermissionGate()` flow.
|
|
On `approved_for_session`, calls `deriveApprovalPrefix()` and `approve()`.
|
|
|
|
2. **Bash external_directory gate** — filters `externalPaths` against `has("external_directory", p)`.
|
|
Uncovered paths go through the prompt; on `approved_for_session`, each is approved.
|
|
|
|
3. **Lifecycle** — `clear()` on `session_shutdown`.
|
|
|
|
## Design Overview
|
|
|
|
### New `SessionRules` class
|
|
|
|
```typescript
|
|
// src/session-rules.ts
|
|
import type { Ruleset } from "./rule";
|
|
|
|
export class SessionRules {
|
|
private rules: Ruleset = [];
|
|
|
|
approve(surface: string, pattern: string): void {
|
|
this.rules.push({ surface, pattern, action: "allow" });
|
|
}
|
|
|
|
getRuleset(): Ruleset {
|
|
return [...this.rules]; // defensive copy
|
|
}
|
|
|
|
clear(): void {
|
|
this.rules = [];
|
|
}
|
|
}
|
|
```
|
|
|
|
### Pattern derivation
|
|
|
|
```typescript
|
|
// src/session-rules.ts
|
|
export function deriveApprovalPattern(normalizedPath: string): string {
|
|
// If the path already ends with separator, it's a directory — glob its contents.
|
|
if (normalizedPath.endsWith(sep)) {
|
|
return `${normalizedPath}*`;
|
|
}
|
|
const dir = dirname(normalizedPath);
|
|
if (dir === normalizedPath) {
|
|
return `${dir}*`; // root
|
|
}
|
|
const prefix = dir.endsWith(sep) ? dir : `${dir}${sep}`;
|
|
return `${prefix}*`;
|
|
}
|
|
```
|
|
|
|
The trailing `*` turns the directory prefix into a wildcard glob that `wildcardMatch()` already handles — `wildcardMatch("/other/project/src/*", "/other/project/src/foo.ts")` returns true.
|
|
|
|
### Integration with `evaluate()`
|
|
|
|
Session rules are the highest-priority layer.
|
|
In the tool_call handler, instead of checking the session cache separately, we concatenate session rules after config rules:
|
|
|
|
```typescript
|
|
const configRules = resolvedPermissions.rules;
|
|
const sessionRuleset = deps.runtime.sessionRules.getRuleset();
|
|
const allRules = [...configRules, ...sessionRuleset];
|
|
```
|
|
|
|
However, this issue **does not** change `checkPermission()` or `resolvePermissions()` to accept session rules — that is #51's job (requires threading session rules through the full permission pipeline).
|
|
|
|
For this issue, the tool_call handler continues to check session approvals in the same position (before prompting), but uses `evaluate("external_directory", normalizedPath, sessionRuleset)` instead of `cache.findMatchingPrefix()`.
|
|
This replaces the custom prefix-matcher with the unified wildcard engine while keeping the handler structure unchanged.
|
|
|
|
### Edge case: sibling directory false positive
|
|
|
|
Current prefix matching (`/other/project/` does NOT match `/other/project-b/foo.ts`) is preserved because the glob `/other/project/*` does not match `/other/project-b/foo.ts` — `wildcardMatch` anchors at `^` and `$`.
|
|
|
|
### Edge case: exact directory match
|
|
|
|
`wildcardMatch("/other/project/src/*", "/other/project/src/")` returns false because `*` requires at least one character after the `/`.
|
|
To match the directory itself, we also store a rule for the exact directory path.
|
|
Alternatively, `deriveApprovalPattern()` returns two rules or uses `**` — but the simplest approach is: when checking, evaluate both the path and the path-with-trailing-content.
|
|
Actually, `wildcardMatch("X/*", "X/")` — the `*` maps to `.*` in regex, which matches zero characters too.
|
|
So `/other/project/src/*` matches `/other/project/src/` (the `*` matches empty string after the final `/`).
|
|
This preserves the current behavior.
|
|
|
|
## Module-Level Changes
|
|
|
|
### `src/session-rules.ts` (new)
|
|
|
|
- `SessionRules` class with `approve(surface, pattern)`, `getRuleset()`, `clear()`.
|
|
- `deriveApprovalPattern(normalizedPath)` — returns a glob string.
|
|
- Imports: `node:path` (dirname, sep), `./rule` (types only).
|
|
|
|
### `src/session-approval-cache.ts` (removed)
|
|
|
|
- Entire file deleted.
|
|
|
|
### `src/runtime.ts`
|
|
|
|
- Replace `SessionApprovalCache` import with `SessionRules`.
|
|
- Replace `sessionApprovalCache: SessionApprovalCache` with `sessionRules: SessionRules` on `ExtensionRuntime`.
|
|
- Construction: `sessionRules: new SessionRules()`.
|
|
|
|
### `src/handlers/tool-call.ts`
|
|
|
|
- Replace `deriveApprovalPrefix` import with `deriveApprovalPattern` from `../session-rules`.
|
|
- Replace `sessionApprovalCache.findMatchingPrefix("external_directory", path)` with an `evaluate("external_directory", path, sessionRuleset)` call — if the returned rule is in the session ruleset, it's a session approval.
|
|
- Replace `sessionApprovalCache.has("external_directory", p)` filter with equivalent `evaluate()` calls.
|
|
- Replace `sessionApprovalCache.approve(...)` calls with `sessionRules.approve("external_directory", deriveApprovalPattern(...))`.
|
|
- Log entries remain the same; `sessionApprovalPrefix` log field becomes `sessionApprovalPattern`.
|
|
|
|
### `src/handlers/lifecycle.ts`
|
|
|
|
- Replace `sessionApprovalCache.clear()` with `sessionRules.clear()`.
|
|
|
|
### `tests/session-approval-cache.test.ts` → `tests/session-rules.test.ts` (renamed)
|
|
|
|
- Rewrite to test `SessionRules` and `deriveApprovalPattern`.
|
|
- Test via `evaluate()` integration: approve a pattern, verify `evaluate("external_directory", path, rules)` returns `allow`.
|
|
- Preserve all edge cases: sibling directory, exact prefix, multiple approvals, surface isolation, clear.
|
|
|
|
### `tests/handlers/tool-call.test.ts`
|
|
|
|
- Update mocks: `sessionApprovalCache` → `sessionRules`.
|
|
- Adjust assertions for `deriveApprovalPattern` (glob) instead of `deriveApprovalPrefix` (prefix).
|
|
|
|
## TDD Order
|
|
|
|
1. **test: add SessionRules unit tests with evaluate() integration**
|
|
- Red: write tests for `SessionRules.approve()`, `getRuleset()`, `clear()`, and `deriveApprovalPattern()`.
|
|
- Green: implement `src/session-rules.ts`.
|
|
- Commit: `test: add SessionRules and deriveApprovalPattern tests`
|
|
|
|
2. **feat: replace SessionApprovalCache with SessionRules in runtime**
|
|
- Update `src/runtime.ts` to use `SessionRules`.
|
|
- Update `src/handlers/lifecycle.ts` to call `sessionRules.clear()`.
|
|
- Update existing runtime tests.
|
|
- Commit: `feat: replace SessionApprovalCache with SessionRules in runtime`
|
|
|
|
3. **feat: migrate tool_call handler to use SessionRules + evaluate()**
|
|
- Replace all `sessionApprovalCache` usage in `src/handlers/tool-call.ts`.
|
|
- Replace `deriveApprovalPrefix` with `deriveApprovalPattern`.
|
|
- Update `tests/handlers/tool-call.test.ts` mocks and assertions.
|
|
- Commit: `feat: migrate tool_call external_directory to SessionRules`
|
|
|
|
4. **feat: remove SessionApprovalCache**
|
|
- Delete `src/session-approval-cache.ts`.
|
|
- Delete or rename `tests/session-approval-cache.test.ts`.
|
|
- Verify no remaining imports.
|
|
- Commit: `feat: remove SessionApprovalCache`
|
|
|
|
5. **docs: update references to SessionApprovalCache**
|
|
- Update any docs or comments referencing the old class.
|
|
- Commit: `docs: update session approval references (#57)`
|
|
|
|
## Risks and Mitigations
|
|
|
|
| Risk | Mitigation |
|
|
| ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| Wildcard semantics differ from prefix semantics, silently widening approval scope | `wildcardMatch` is anchored (`^...$`); `/dir/*` cannot match `/dir-sibling/file`. Explicit test for sibling directory false positive. |
|
|
| `*` in `wildcardMatch` matches empty string, so `/dir/*` matches `/dir/` — is this intended? | Yes, this preserves current behavior where approving `/dir/` covers `isPathWithinDirectory(path, "/dir/")`. |
|
|
| Changing `runtime.sessionApprovalCache` to `runtime.sessionRules` breaks any external consumers | `ExtensionRuntime` is internal; no public API contract. Only our own handlers consume it. |
|
|
| Could this silently weaken a permission? | No — session rules are `allow`-only and only apply to paths the user has already explicitly approved via the dialog. The `evaluate()` last-match-wins semantics mean session rules override config rules, which is the intended behavior (user said "yes for this session"). |
|
|
|
|
## Open Questions
|
|
|
|
- Should `SessionRules` deduplicate patterns on `approve()`?
|
|
Current `SessionApprovalCache` uses a `Set` which deduplicates.
|
|
A `Ruleset` array does not.
|
|
Deduplication is a minor optimization — defer unless profiling shows repeated approvals cause slowdown.
|
|
Decision: skip deduplication for now; `evaluate()` handles duplicates correctly (last match wins, all are `allow`).
|