mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
331 lines
17 KiB
Markdown
331 lines
17 KiB
Markdown
---
|
|
issue: 88
|
|
issue_title: "Track and report provenance of each permission rule"
|
|
---
|
|
|
|
# Plan: Track and report provenance of each permission rule
|
|
|
|
## Problem Statement
|
|
|
|
When a permission decision (allow/deny/ask) is made, there is no way to determine which config source contributed the winning rule.
|
|
All four config sources — global, project, agent frontmatter, and project-agent frontmatter — are merged into a single `FlatPermissionConfig` before rules are created, so every config rule receives the generic `layer: "config"` label.
|
|
Debugging "why is tool X denied?"
|
|
requires manually inspecting up to four config locations.
|
|
The review log records the decision but not its source, and `/permission-system` cannot show where each effective rule came from.
|
|
|
|
Additionally, the `"override"` value in the `Rule.layer` type union is dead code — introduced in #65, the layer value was removed in #66 but left in the type.
|
|
The `deriveSource()` function still has a branch for `layer === "override"` that is unreachable.
|
|
|
|
## Goals
|
|
|
|
- Add an optional `origin` field to `Rule` that records which config scope contributed the rule.
|
|
- Tag config rules with their origin during the merge loop in `resolvePermissions()`.
|
|
- Propagate origin into `PermissionCheckResult` so callers can report it.
|
|
- Include origin in review log entries for permission decisions.
|
|
- Include origin in `/permission-system show` output when displaying effective policy.
|
|
- Remove the dead `"override"` value from the `Rule.layer` type union and its unreachable `deriveSource()` branch.
|
|
- Add tests for provenance correctness across merge-precedence scenarios.
|
|
|
|
## Non-Goals
|
|
|
|
- Changing the merge-precedence semantics (global → project → agent → project-agent).
|
|
Origin tracking is read-only metadata; it must not alter any permission decision.
|
|
- Displaying origin in the interactive permission prompt dialog (deferred — see issue § "Permission dialog").
|
|
- Tracking origin for synthesized defaults (`layer: "default"`) or baseline rules (`layer: "baseline"`).
|
|
Only `layer: "config"` rules carry origin.
|
|
- Adding origin to session rules (`layer: "session"`).
|
|
Session rules are runtime-only and always come from the current session.
|
|
|
|
## Background
|
|
|
|
### Relevant modules
|
|
|
|
| File | Role |
|
|
| ------------------------------------------ | ----------------------------------------------------------------------------------- |
|
|
| `src/rule.ts` | `Rule` interface, `Ruleset`, `evaluate()`, `evaluateFirst()` |
|
|
| `src/types.ts` | `PermissionCheckResult`, `FlatPermissionConfig`, `ScopeConfig` |
|
|
| `src/normalize.ts` | `normalizeFlatConfig()` — converts flat config to `Ruleset` |
|
|
| `src/synthesize.ts` | `synthesizeDefaults()`, `synthesizeBaseline()`, `composeRuleset()` |
|
|
| `src/permission-manager.ts` | `PermissionManager` — `resolvePermissions()`, `checkPermission()`, `deriveSource()` |
|
|
| `src/permission-prompter.ts` | `PermissionPrompter` — writes review log entries with permission details |
|
|
| `src/logging.ts` | `createPermissionSystemLogger()` — writes review and debug log lines |
|
|
| `src/config-modal.ts` | `/permission-system` slash command handler |
|
|
| `docs/architecture/target-architecture.md` | Living architecture doc with `Rule` type definition |
|
|
|
|
### Permission surfaces involved
|
|
|
|
All surfaces (tools, bash, mcp, skills, special/external_directory).
|
|
Origin is surface-agnostic metadata on `Rule`.
|
|
|
|
### Current merge flow
|
|
|
|
`resolvePermissions()` merges four `FlatPermissionConfig` objects using `mergeFlatPermissions()` (deep-shallow merge), normalizes the merged result into rules via `normalizeFlatConfig()`, and stamps every rule with `layer: "config"`.
|
|
The origin of each rule is lost at the `mergeFlatPermissions()` step.
|
|
|
|
## Design Overview
|
|
|
|
### New `RuleOrigin` type and `Rule.origin` field
|
|
|
|
```typescript
|
|
/** Which config scope contributed a rule. Only set for layer="config". */
|
|
export type RuleOrigin = "global" | "project" | "agent" | "project-agent";
|
|
|
|
export interface Rule {
|
|
surface: string;
|
|
pattern: string;
|
|
action: PermissionState;
|
|
layer?: "default" | "baseline" | "config" | "session"; // "override" removed
|
|
origin?: RuleOrigin;
|
|
}
|
|
```
|
|
|
|
### Origin tracking strategy
|
|
|
|
The current `resolvePermissions()` loop merges flat configs then normalizes.
|
|
Changing this to per-scope normalization + concatenation would subtly alter the deep-shallow merge semantics (e.g., when a higher-precedence scope replaces a lower scope's object entry with a string, the lower scope's pattern rules would incorrectly survive as last-match-wins candidates).
|
|
|
|
Instead, build a parallel **origin map** alongside the existing merge loop — it mirrors `mergeFlatPermissions()` semantics exactly without changing any permission decision:
|
|
|
|
```typescript
|
|
type OriginMap = Map<string, Map<string, RuleOrigin>>;
|
|
|
|
const origins: OriginMap = new Map();
|
|
|
|
for (const [scopeName, scope] of [
|
|
["global", globalConfig],
|
|
["project", projectConfig],
|
|
["agent", agentConfig],
|
|
["project-agent", projectAgentConfig],
|
|
] as const) {
|
|
if (!scope.permission) continue;
|
|
|
|
for (const [surface, value] of Object.entries(scope.permission)) {
|
|
const baseVal = mergedPermission[surface];
|
|
const bothObjects =
|
|
typeof baseVal === "object" && baseVal !== null &&
|
|
typeof value === "object" && value !== null;
|
|
|
|
if (bothObjects) {
|
|
// Shallow merge: new patterns attributed to this scope,
|
|
// existing patterns keep their earlier origin.
|
|
if (!origins.has(surface)) origins.set(surface, new Map());
|
|
for (const pattern of Object.keys(value as Record<string, unknown>)) {
|
|
origins.get(surface)!.set(pattern, scopeName);
|
|
}
|
|
} else {
|
|
// Full replacement: reset all origins for this surface.
|
|
const surfaceOrigins = new Map<string, RuleOrigin>();
|
|
if (typeof value === "string") {
|
|
surfaceOrigins.set("*", scopeName);
|
|
} else if (typeof value === "object" && value !== null) {
|
|
for (const pattern of Object.keys(value as Record<string, unknown>)) {
|
|
surfaceOrigins.set(pattern, scopeName);
|
|
}
|
|
}
|
|
origins.set(surface, surfaceOrigins);
|
|
}
|
|
}
|
|
|
|
// Existing merge (unchanged)
|
|
mergedPermission = mergeFlatPermissions(mergedPermission, scope.permission);
|
|
}
|
|
```
|
|
|
|
After normalization, stamp each config rule:
|
|
|
|
```typescript
|
|
const configRules: Ruleset = normalizeFlatConfig(permissionWithoutUniversal)
|
|
.map((r): Rule => ({
|
|
...r,
|
|
layer: "config",
|
|
origin: origins.get(r.surface)?.get(r.pattern),
|
|
}));
|
|
```
|
|
|
|
The universal fallback `permission["*"]` also needs origin tracking.
|
|
Track it separately:
|
|
|
|
```typescript
|
|
const universalFallbackOrigin: RuleOrigin | undefined =
|
|
origins.get("*")?.get("*");
|
|
```
|
|
|
|
Pass it to `synthesizeDefaults()` so the synthesized default rule can optionally carry an origin when it came from a user config (not the built-in fallback).
|
|
|
|
### `PermissionCheckResult.origin`
|
|
|
|
Add an optional `origin` field to `PermissionCheckResult`:
|
|
|
|
```typescript
|
|
export interface PermissionCheckResult {
|
|
toolName: string;
|
|
state: PermissionState;
|
|
matchedPattern?: string;
|
|
command?: string;
|
|
target?: string;
|
|
source: "tool" | "bash" | "mcp" | "skill" | "special" | "default" | "session";
|
|
/** Which config scope contributed the winning rule (only for config rules). */
|
|
origin?: RuleOrigin;
|
|
}
|
|
```
|
|
|
|
In `checkPermission()`, propagate `rule.origin` into the result.
|
|
|
|
### Review log entries
|
|
|
|
The review log entries already include fields from `PermissionCheckResult` (via `getPermissionLogContext()` and `PermissionPrompter.writeReviewEntry()`).
|
|
Add `origin` to the structured log details wherever a check result is logged.
|
|
This requires changes to:
|
|
|
|
- `src/tool-input-preview.ts` — `getPermissionLogContext()` includes `origin` from the check result.
|
|
- `src/permission-gate.ts` — `logContext` type gains an optional `origin` field.
|
|
- `src/handlers/tool-call.ts` — pass `origin` through `logContext` in the normal tool permission gate.
|
|
|
|
### `/permission-system show` output
|
|
|
|
Extend the `show` subcommand to display the composed config-layer rules with their origins.
|
|
Example output:
|
|
|
|
```text
|
|
permission-system: yoloMode=off, permissionReviewLog=on, debugLog=off
|
|
rules: read=allow (global), bash["*"]=allow (global), bash["rm *"]=deny (project), mcp["exa:*"]=allow (agent)
|
|
```
|
|
|
|
This requires `config-modal.ts` to accept a function that returns the composed ruleset (or a formatted summary) from the `PermissionManager`.
|
|
|
|
### Remove dead `"override"` layer
|
|
|
|
- Remove `"override"` from the `Rule.layer` union in `src/rule.ts`.
|
|
- Remove the `if (rule.layer === "override") return "tool";` branch from `deriveSource()` in `src/permission-manager.ts`.
|
|
- Update the `Rule` type in `docs/architecture/target-architecture.md`.
|
|
|
|
## Module-Level Changes
|
|
|
|
### `src/rule.ts`
|
|
|
|
- Add `export type RuleOrigin = "global" | "project" | "agent" | "project-agent";`.
|
|
- Add `origin?: RuleOrigin` to the `Rule` interface.
|
|
- Remove `"override"` from the `layer` union.
|
|
|
|
### `src/types.ts`
|
|
|
|
- Re-export `RuleOrigin` (or import it) and add `origin?: RuleOrigin` to `PermissionCheckResult`.
|
|
|
|
### `src/normalize.ts`
|
|
|
|
- No functional changes.
|
|
`normalizeFlatConfig()` returns rules without `layer` or `origin`; callers stamp them.
|
|
|
|
### `src/synthesize.ts`
|
|
|
|
- `synthesizeDefaults()` gains an optional `origin` parameter so the universal default rule can carry an origin when it was set by a user config rather than the built-in fallback.
|
|
|
|
### `src/permission-manager.ts`
|
|
|
|
- `resolvePermissions()`: build an `OriginMap` alongside the merge loop; stamp config rules with `origin` after normalization.
|
|
Pass `universalFallbackOrigin` to `synthesizeDefaults()`.
|
|
- `checkPermission()`: include `rule.origin` in the returned `PermissionCheckResult`.
|
|
- `deriveSource()`: remove the `if (rule.layer === "override")` branch and its JSDoc entry.
|
|
- `getToolPermission()`: no change — it returns `PermissionState`, not `PermissionCheckResult`.
|
|
|
|
### `src/tool-input-preview.ts`
|
|
|
|
- `getPermissionLogContext()`: include `origin` from the check result in the returned log details.
|
|
|
|
### `src/config-modal.ts`
|
|
|
|
- `PermissionSystemConfigController`: add an optional `getComposedRules?: () => Ruleset` method.
|
|
- `summarizeConfig()`: append a compact rules-with-origin summary when composed rules are available.
|
|
- `handleArgs()` `show` branch: pass composed rules into the summary.
|
|
|
|
### `src/index.ts`
|
|
|
|
- Pass a `getComposedRules` callback to `registerPermissionSystemCommand` that calls `runtime.permissionManager`.
|
|
|
|
### `src/handlers/tool-call.ts`
|
|
|
|
- No structural changes.
|
|
`logContext` objects already spread `getPermissionLogContext(check, ...)`, which will now include `origin`.
|
|
|
|
### `docs/architecture/target-architecture.md`
|
|
|
|
- Update `Rule` type definition: remove `"override"` from `layer`, add `origin?: RuleOrigin`.
|
|
- Add a note in the "Composed Ruleset" diagram about origin metadata on config rules.
|
|
|
|
### `tests/`
|
|
|
|
- `tests/rule.test.ts` — verify `evaluate()` preserves `origin` on matched rules.
|
|
- `tests/permission-manager-unified.test.ts` — add provenance tests covering:
|
|
- Single-scope origin attribution.
|
|
- Multi-scope deep-shallow merge (both-object): each pattern's origin is correct.
|
|
- Replacement semantics (string replaces object, object replaces string): origins reset.
|
|
- Universal fallback origin.
|
|
- `PermissionCheckResult.origin` propagation.
|
|
- `tests/synthesize.test.ts` — test that `synthesizeDefaults()` passes through an origin when provided.
|
|
- `tests/config-modal.test.ts` — test that `show` includes origin annotations when composed rules are available.
|
|
|
|
## TDD Order
|
|
|
|
### 1. Remove dead `"override"` layer value
|
|
|
|
- **Test surface**: `tests/rule.test.ts`, `tests/permission-manager-unified.test.ts` (type-check via `pnpm run build`).
|
|
- **What's covered**: `"override"` removed from `Rule.layer` union; `deriveSource()` branch removed; architecture doc updated.
|
|
Existing tests that reference `layer` continue to pass since no test used `"override"`.
|
|
- **Commit**: `refactor: remove dead "override" layer value from Rule type`
|
|
|
|
### 2. Add `RuleOrigin` type and `origin` field to `Rule`
|
|
|
|
- **Test surface**: `tests/rule.test.ts`.
|
|
- **What's covered**: `RuleOrigin` type exported; `Rule.origin` accepted by `evaluate()`; `evaluate()` preserves `origin` on matched rules (new test cases).
|
|
- **Commit**: `feat: add RuleOrigin type and origin field to Rule`
|
|
|
|
### 3. Tag config rules with origin during `resolvePermissions()`
|
|
|
|
- **Test surface**: `tests/permission-manager-unified.test.ts`.
|
|
- **What's covered**: new `describe` block for provenance — single-scope, multi-scope merge, replacement semantics, universal fallback origin.
|
|
Tests call `checkPermission()` and assert `result.origin` values.
|
|
Requires adding `origin` to `PermissionCheckResult` so `checkPermission()` can return it.
|
|
- **Commit**: `feat: track and propagate rule origin through checkPermission`
|
|
|
|
### 4. Propagate origin to `synthesizeDefaults()`
|
|
|
|
- **Test surface**: `tests/synthesize.test.ts`, `tests/permission-manager-unified.test.ts`.
|
|
- **What's covered**: `synthesizeDefaults(universalDefault, origin?)` passes `origin` to the default rule; `checkPermission()` returns origin when the universal fallback was set by a user config.
|
|
- **Commit**: `feat: propagate origin to synthesized default rule`
|
|
|
|
### 5. Include origin in review log entries
|
|
|
|
- **Test surface**: `tests/handlers/tool-call.test.ts` (or a new `tests/tool-input-preview.test.ts` if not yet covered).
|
|
- **What's covered**: `getPermissionLogContext()` includes `origin` from the check result; review log entries contain the winning rule's origin.
|
|
- **Commit**: `feat: include rule origin in permission review log entries`
|
|
|
|
### 6. Display origin in `/permission-system show` output
|
|
|
|
- **Test surface**: `tests/config-modal.test.ts`.
|
|
- **What's covered**: `show` subcommand output includes per-rule origin annotations when composed rules are available; omits them when not.
|
|
- **Commit**: `feat: display rule origins in /permission-system show output`
|
|
|
|
### 7. Update architecture doc
|
|
|
|
- **Test surface**: manual review only.
|
|
- **What's covered**: `docs/architecture/target-architecture.md` updated with `RuleOrigin`, `origin` field, and removal of `"override"`.
|
|
- **Commit**: `docs: update target architecture for rule origin provenance`
|
|
|
|
## Risks and Mitigations
|
|
|
|
| Risk | Mitigation |
|
|
| ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
| Origin tracking diverges from merge semantics, causing incorrect attribution. | The origin map mirrors `mergeFlatPermissions()` case-by-case (both-objects, string-replaces, object-replaces). Unit tests cover all three merge modes. |
|
|
| Adding `origin` to `Rule` or `PermissionCheckResult` breaks deep-equality assertions in existing tests. | `origin` is optional and only set for config rules. Existing tests that use `makeManager()` with no config will see rules without `origin`, preserving deep equality. Tests with config fixtures may need `origin` in expected values — addressed in step 3. |
|
|
| Could this silently weaken a permission? | No. `origin` is read-only metadata. It is not consumed by `evaluate()`, does not appear in any guard condition, and does not alter any allow/deny/ask decision. The `evaluate()` function's behavior is unchanged. |
|
|
| Removing `"override"` layer breaks a runtime path. | No code path creates a rule with `layer: "override"`. The `deriveSource()` branch is unreachable. Removing both is safe. `pnpm run build` confirms no type errors. |
|
|
| `/permission-system show` output becomes noisy with many rules. | Keep the display compact (one line per rule, abbreviated origin labels). If the rule count exceeds a threshold, truncate with a count summary. |
|
|
|
|
## Open Questions
|
|
|
|
- Should the `origin` field also appear on synthesized default and baseline rules (e.g., `origin: "builtin"`)?
|
|
The issue scopes it to `layer: "config"` only; this could be revisited if debugging of defaults becomes a pain point.
|
|
- Should origin be exposed in the interactive permission dialog prompt (e.g., "This permission comes from your project config")?
|
|
The issue mentions this as a future benefit but does not include it in scope.
|
|
Deferred.
|