feat: 组合包配置以包内 config/ 为唯一来源,并新增受保护写入人工审批

配置统一:权限策略、硬防御、Tool Search、提示词封面以及新迁入的
RTK、pi-compaction、pi-worktree、pi-hermes-memory 配置全部由扩展
直接读取已安装组合包内的 config/,pi install / pi update 即完成部署;
删除 rebuild-bundle-config.mjs 与 bundle-manifest.json,my-pi/ 只保留
日志与缓存。update.sh 成功升级后将默认 git 来源的已安装副本
reset 回发布版本。AGENTS.md 新增「组合包配置原则」。

脚本整合:search_config.sh / ssh_config.sh 合并为 configure.sh
search|ssh;install.sh / update.sh 共享函数抽到 scripts/lib.sh,远程
安装器改为先下载到临时文件再执行,下载失败可被检测。

权限:hard-guards 新增 protectedWrites,写入 Pi 凭据/设置、已安装
组合包与扩展目录、pi-ssh 凭据库时一律人工确认(跳过 AutoReview、
yolo 与 Session grant,无 UI 时拒绝);放行 ls、LSP 查询、ctx 只读、
web_search、Chrome 只读与 git 只读命令,skill_manage 改为 ask。

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
云服务部-叶林立
2026-10-20 01:05:31 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent a0ee9389c7
commit 2aa1be0b54
105 changed files with 2337 additions and 1413 deletions
+11 -7
View File
@@ -19,6 +19,7 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin
- **Uses one global ordinary policy file**; project `policy.jsonc` files are not loaded
- **Chooses the most specific matching pattern**, so a specific deny wins over a broad `"*": "allow"` regardless of declaration order
- **Applies a separate hard-guard policy first** so session grants, per-agent rules, and model review cannot override high-risk denies
- **Keeps protected writes human-only**: files listed in `protectedWrites` (Pi credentials, settings, the installed bundle itself, …) always reach the local human prompt — never AutoReview, never yolo, never a session grant — and are denied when no confirmation UI exists. Reads are unaffected.
- **Controls Bash, paths, MCP, skills, and tool surfaces** while leaving active-tool ownership entirely to Tool Search
- **Reviews ordinary `ask` requests automatically** with integrated AutoReview; its `allow` verdict is final for ordinary tools and `path`, while `defer` reaches the next authorizer or human terminal
- **Follows the session's current model**: AutoReview asks the model you are already chatting with to judge each ask, so the review always reflects the model the operator actually selected; set `autoReview.provider` and `autoReview.model` together to pin a different reviewer
@@ -34,7 +35,7 @@ pi install npm:@gotgenes/pi-permission-system
## Quick Start
1. Create ordinary policy at `~/.pi/agent/my-pi/permission-system/policy.jsonc`:
1. Ordinary policy lives in the installed bundle at `config/pi-permission-policy.jsonc` (the bundle repository's `config/` directory — edit it there for durable changes):
```jsonc
{
@@ -51,7 +52,7 @@ pi install npm:@gotgenes/pi-permission-system
}
```
2. Create independent hard guards at `~/.pi/agent/my-pi/permission-system/hard-guards.jsonc`:
2. Independent hard guards live beside it at `config/pi-permission-hard-guards.jsonc`:
```jsonc
{
@@ -62,7 +63,10 @@ pi install npm:@gotgenes/pi-permission-system
"*.env.*": "deny",
"*.env.example": "allow",
"~/.ssh/**": "deny"
}
},
"protectedWrites": [
{ "pattern": "{agentDir}/auth.json", "label": "Pi credentials" }
]
}
```
@@ -86,12 +90,12 @@ The integrated extension uses two independent global JSONC files:
| Purpose | Scope | Path |
| ------- | ----- | ---- |
| Ordinary policy, runtime knobs, AutoReview | Global only | `~/.pi/agent/my-pi/permission-system/policy.jsonc` |
| Non-overridable hard guards | Global only | `~/.pi/agent/my-pi/permission-system/hard-guards.jsonc` |
| Ordinary policy, runtime knobs, AutoReview | Global only | `config/pi-permission-policy.jsonc` inside the installed bundle |
| Non-overridable hard guards | Global only | `config/pi-permission-hard-guards.jsonc` inside the installed bundle |
Project-level `policy.jsonc` files are ignored. Project agent Markdown remains a separate per-agent restriction mechanism.
The bundle deploys both files with mode `0600` via `scripts/rebuild-bundle-config.mjs`: every `install.sh` / `update.sh` run rebuilds the whole `<agentDir>/my-pi/` tree, overwriting local edits without a backup — keep durable changes in the repository's `config/` sources. Files left at the former `extensions/pi-permission-system/` location are ignored.
Both files are read directly from the bundle's `config/` directory — the single source of truth. Nothing is deployed to, seeded into, or migrated from the agent directory; `pi update` refreshes them with the bundle. Files left at the former `~/.pi/agent/my-pi/permission-system/` location are ignored.
Within a pattern map, the most specific match wins: exact patterns, then more literal characters, fewer wildcards, and longer patterns. Declaration order is only a tie-breaker.
@@ -99,7 +103,7 @@ There is no `external_directory` gate. Outside-CWD paths use the same configured
Tool Search alone owns Pi's active tool list. Permission System only decides whether an invoked tool call is allowed, denied, or requires confirmation.
See [docs/configuration.md](docs/configuration.md) for the complete reference and [docs/hard-guards.md](docs/hard-guards.md) for the independent hard-guard format.
See [docs/configuration.md](docs/configuration.md) for the complete reference, including the [protected writes](docs/configuration.md#protected-writes) format in the hard-guard file.
## Documentation
@@ -1,17 +0,0 @@
{
// User-configurable, global-only hard boundary. Project policy, session
// grants and AutoReview cannot override a matching deny.
"version": 1,
"permission": {
"bash": {
"rm -rf /": "deny",
"rm -rf /*": "deny",
"rm -rf ~": "deny",
"rm -rf ~/*": "deny",
"sudo rm -rf /": "deny",
"sudo rm -rf /*": "deny",
"mkfs*": "deny",
"dd * of=/dev/*": "deny"
}
}
}
@@ -517,6 +517,8 @@ The public authorizer registry remains available for other named authorities. Co
Authorizers are named capabilities registered on `permissions:ready`; the integrated `auto-review` authorizer uses the same chain contract as third-party links. Once configured, a link's `allow` or `deny` is final for any ordinary-policy `ask`, including `path`; `defer` advances to the next link or terminal.
One configured path bypasses the chain entirely: `protectedWrites` in the hard-guard file marks write targets as human-only. The protected-write gate produces its `ask` last in the tool-call pipeline (a hard deny still blocks first); the descriptor's `humanOnly` marker skips the runner's session-approval and yolo fast paths, and `AuthorizerSelection` composes it with zero links so no link — including `auto-review` — is consulted. A `LocalUserAuthorizer` terminal prompts without a "for this session" option; any other terminal (`DenyingAuthorizer`, a `ParentAuthorizer` relay) denies without prompting. Reads never reach the gate.
This is the principled successor to the per-command argument-position work deferred from [#509].
The bash path projection surfaces a bare token that names a real file ([#645]) and deliberately accepts a fail-safe false positive (`grep id_rsa secrets.txt` prompts when an `id_rsa` file happens to exist); that false positive lives on the *ask-producing* side of `evaluate()`, and the model link dismisses it on the *ask-consuming* side without hard-coding per-command file-argument tables.
This split is the layering principle of [ADR 0009](../decisions/0009-bash-path-projection-completeness-contract.md): the deterministic layer biases toward surfacing because over-suppression is unrecoverable, and the judge absorbs the surplus.
@@ -578,7 +580,7 @@ src/
├── index.ts Composition root and Pi event registration
├── config-loader.ts Global ordinary policy loading
├── policy-loader.ts Global policy + active-agent frontmatter gateway
├── hard-guard-policy-loader.ts Independent global-only hard-guard loading
├── hard-guard-loader.ts Independent global-only hard-guard + protectedWrites loading
├── rule.ts Pattern matching and specificity ranking
├── permission-manager.ts Unified intent evaluation
├── access-intent/ Resolved intent types and resolver
+28 -5
View File
@@ -2,16 +2,16 @@
## Config File Locations
Pi Permission System reads two user-level JSONC files:
Pi Permission System reads two JSONC files from the installed bundle's `config/` directory (the single source of truth; `pi update` refreshes them with the bundle):
| Purpose | Scope | Path |
| ------- | ----- | ---- |
| Ordinary policy + runtime | Global only | `~/.pi/agent/my-pi/permission-system/policy.jsonc` |
| Hard guards | Global only | `~/.pi/agent/my-pi/permission-system/hard-guards.jsonc` |
| Ordinary policy + runtime | Global only | `config/pi-permission-policy.jsonc` |
| Hard guards | Global only | `config/pi-permission-hard-guards.jsonc` |
`PI_CODING_AGENT_DIR` replaces `~/.pi/agent` when set. Project-level `policy.jsonc` files are not loaded. Project agent Markdown may still provide per-agent restrictions; it is not a second ordinary policy file.
Project-level `policy.jsonc` files are not loaded. Project agent Markdown may still provide per-agent restrictions; it is not a second ordinary policy file.
Hard guards are evaluated first. Their matching `deny` decisions cannot be overridden by ordinary policy, session approvals, per-agent rules, or AutoReview.
Hard guards are evaluated first. Their matching `deny` decisions cannot be overridden by ordinary policy, session approvals, per-agent rules, or AutoReview. The same file's `protectedWrites` list marks files whose *writes* always reach a human prompt — see [Protected writes](#protected-writes).
Tool Search is the only extension that controls Pi's active tool list. Permission System never hides, activates, or replaces tools; it only evaluates a tool when Pi calls it.
@@ -37,6 +37,27 @@ This makes a broad `"*": "allow"` a fallback rather than an ordering trick. For
There is no separate `external_directory` decision. A path outside the current working directory is evaluated by the same configured tool, `path`, and Bash rules as any other path.
## Protected writes
`protectedWrites` in the hard-guard file lists files and subtrees whose **writes** must always be confirmed by a human:
```jsonc
{
"version": 1,
"permission": { "bash": { "rm -rf /": "deny" } },
"protectedWrites": [
{ "pattern": "{agentDir}/auth.json", "label": "Pi credentials" },
{ "pattern": "{configHome}/my-pi/**", "label": "pi-ssh credential store" }
]
}
```
Each entry is `{ "pattern": string, "label": string }`. `pattern` uses the same wildcard syntax as the `path` surface (`*` crosses `/`, so a trailing `/**` covers the whole subtree) and is matched against both the lexical and canonical (symlink-resolved) forms of a write target. `{agentDir}` expands to the Pi agent directory (`PI_CODING_AGENT_DIR`), `{configHome}` to `$XDG_CONFIG_HOME` or `~/.config`, and a leading `~/` to the home directory. `label` is the name shown in the prompt and review log.
Counts as a write: a `write`/`edit` tool call's `input.path`, and a local `bash` command's redirect-out destinations and non-read-only program arguments — including a `bash -c`/`eval`-style opaque payload that textually contains a protected path, an unparseable command, and a relative write after `cd` into a protected directory. Reads of protected paths are unaffected: `cat` and friends are not writes, and neither is a redirect-in (`<`). Aliased shell tools (`shellTools`, e.g. `ssh_bash`) address remote filesystems and are never matched.
A protected write produces a human-only `ask`: it skips the session-approval and yolo fast paths, composes with zero `authorizerChain` links (AutoReview is never consulted), and the dialog offers no "for this session" option. With no local confirmation UI — a headless session or a subagent relay terminal — the write is denied without prompting. A malformed `protectedWrites` entry makes the whole hard-guard file invalid and follows its fail-closed semantics.
## Full Example
```jsonc
@@ -244,6 +265,8 @@ Three invariants govern the chain:
A named authorizer may return a final `allow` or `deny` for any request that ordinary policy resolved to `ask`, including `path`. There is no additional surface-based delegation cap. `defer` advances to the next configured authorizer and ultimately the human terminal.
The one exception is a [protected write](#protected-writes): its `ask` is human-only and never reaches the chain — `authorizerChain` links are not consulted, and a terminal that is not the local user prompt denies without prompting.
Extension authors register a link from `permissions:ready` via `getPermissionsService().registerAuthorizer(name, authorize)`. For integration details, see [Cross-Extension API](cross-extension-api.md).
---
@@ -49,7 +49,7 @@ If your OpenCode config uses these features, the equivalent works in this extens
| `mcp` surface | Not a documented permission surface | First-class with server/tool-level granularity |
| Top-level string shorthand | `"permission": "allow"` sets all surfaces | Not supported; must use an object |
| Per-agent config location | `agent` key in config JSON or YAML frontmatter | YAML frontmatter in agent `.md` files only |
| Config file paths | `~/.config/opencode/opencode.json` | `~/.pi/agent/my-pi/permission-system/policy.jsonc` plus global `hard-guards.jsonc` |
| Config file paths | `~/.config/opencode/opencode.json` | `config/pi-permission-policy.jsonc` plus `config/pi-permission-hard-guards.jsonc` inside the installed bundle |
| Subagent prompt forwarding | Not documented | `ask` policies work in non-UI subagent contexts |
| Infrastructure auto-allow | N/A | Read-only tools to Pi infra dirs bypass the gate |
| Permission review log | No equivalent documented | Writes decisions to a JSONL audit log |
+5 -8
View File
@@ -4,13 +4,15 @@
| Problem | Cause | Solution |
| ----------------------------------------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| Config not applied (everything asks) | File not found or parse error | Verify the global policy at `~/.pi/agent/my-pi/permission-system/policy.jsonc` (respects `PI_CODING_AGENT_DIR`); check JSONC syntax |
| Config not applied (everything asks) | File not found or parse error | Verify the global policy at `config/pi-permission-policy.jsonc` inside the installed bundle; check JSONC syntax |
| Per-agent override not applied | Frontmatter parsing issue | Ensure `---` delimiters at file top; keep YAML simple; restart session |
| Tool blocked as unregistered | Unknown tool name | Server tools arrive as `mcp__<server>__<tool>` under Pi's built-in MCP (check `/mcp`); a unified `mcp` proxy tool uses `{ "tool": "server:tool" }` |
| `/skill:<name>` blocked | Deny policy or confirmation unavailable | Check merged `skill` policy (global/project/agent layers). `ask` still requires UI or forwarded confirmation. |
| External file path blocked | `external_directory` is `ask` without UI or `deny` | Allow/ask the permission or keep file tools inside the active working directory. |
| Spurious external-path prompt for `cd <subdir> && grep … ../path` | Relative path was resolved against cwd instead of the `cd` target | Fixed in current version — paths after a leading `cd <subdir> &&` are resolved against the cd target, matching actual shell behavior. |
| Permission prompt is too verbose | Generic extension tool input is large | Built-in file tools are summarized automatically; third-party tools are capped to a bounded one-line JSON preview. |
| A file write keeps prompting despite a session grant or yolo | The target is in `protectedWrites` | Working as designed — protected writes are human-only and never take a session grant. Remove the entry from `protectedWrites` in `config/pi-permission-hard-guards.jsonc` to lift it. |
| A file write is denied without any prompt | `protectedWrites` target, no confirmation UI (headless or subagent relay) | Working as designed — a human-only ask cannot be answered without a local UI. Run the write from an interactive session instead. |
## Diagnostic Logging
@@ -22,17 +24,12 @@ This makes it easy to verify which files the extension actually loaded:
```jsonc
{
"event": "config.resolved",
"globalConfigPath": "/…/.pi/agent/my-pi/permission-system/policy.jsonc",
"globalConfigPath": "/…/bundle/config/pi-permission-policy.jsonc",
"globalConfigExists": true,
"projectConfigPath": "/…/my-project/.pi/extensions/pi-permission-system/policy.jsonc",
"projectConfigExists": false,
"agentsDir": "/…/.pi/agent/agents",
"agentsDirExists": true,
"projectAgentsDir": "/…/my-project/.pi/agents",
"projectAgentsDirExists": false,
"legacyGlobalPolicyDetected": false,
"legacyProjectPolicyDetected": false,
"legacyExtensionConfigDetected": false
"projectAgentsDirExists": false
}
```
-1
View File
@@ -18,7 +18,6 @@
"src",
"dist",
"config/config.example.json",
"config/hard-guards.default.jsonc",
"schemas/permissions.schema.json",
"schemas/hard-guards.schema.json",
"docs/*.md",
@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/siakitem/my-pi/main/pi-permission-system/schemas/hard-guards.schema.json",
"title": "Pi Permission System Hard Guards",
"description": "Global-only allow exceptions and non-overridable deny rules evaluated before ordinary policy, session grants, and AutoReview.",
"description": "Global-only allow exceptions and non-overridable deny rules evaluated before ordinary policy, session grants, and AutoReview, plus protectedWrites whose writes always escalate to a human prompt.",
"type": "object",
"properties": {
"$schema": { "type": "string" },
@@ -18,6 +18,19 @@
}
]
}
},
"protectedWrites": {
"type": "array",
"description": "Files and subtrees whose writes are human-only: every write goes to a local human prompt, skipping AutoReview, yolo, and session grants, and is denied when no UI is available. Patterns use the path surface wildcard syntax; {agentDir}, {configHome} and a leading ~/ are expanded at load.",
"items": {
"type": "object",
"properties": {
"pattern": { "type": "string", "minLength": 1 },
"label": { "type": "string", "minLength": 1 }
},
"required": ["pattern", "label"],
"additionalProperties": false
}
}
},
"required": ["version", "permission"],
@@ -266,7 +266,7 @@
"additionalProperties": false,
"title": "PI Permission System Configuration",
"description": "Ordinary JSONC policy combining runtime knobs, AutoReview settings, and flat permission rules for pi-permission-system.",
"markdownDescription": "Global ordinary JSONC policy for [pi-permission-system](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system).\n\nPlace at `~/.pi/agent/my-pi/permission-system/policy.jsonc`. Project-level policy files are not loaded. Global-only hard guards use the separate `hard-guards.jsonc` schema.",
"markdownDescription": "Global ordinary JSONC policy for [pi-permission-system](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system).\n\nRead directly from the installed bundle's `config/pi-permission-policy.jsonc`. Project-level policy files are not loaded. Global-only hard guards use the separate `config/pi-permission-hard-guards.jsonc` schema.",
"$defs": {
"permissionState": {
"anyOf": [
@@ -9,9 +9,11 @@ import {
classifyTokenAsRuleCandidate,
} from "#src/access-intent/bash/token-classification";
import {
collectCommandTokens,
collectPathCandidateTokens,
collectRedirectTokens,
type BashPathTokenRole,
type CollectedPathToken,
collectCommandTokensDetailed,
collectPathCandidateTokensDetailed,
collectRedirectTokensDetailed,
extractCommandName,
} from "#src/access-intent/bash/token-collection";
import { normalizePathPolicyLiteral } from "#src/access-intent/path-normalization";
@@ -40,6 +42,9 @@ type EffectiveBase =
interface PathCandidate {
readonly token: string;
readonly base: EffectiveBase;
readonly role: BashPathTokenRole;
readonly program: string | null;
readonly wrapped: boolean;
}
// ── Public output types ──────────────────────────────────────────────────────
@@ -49,13 +54,41 @@ export interface BashPathRuleCandidate {
readonly token: string;
/** The path's lexical and canonical forms for permission policy matching. */
readonly path: AccessPath;
/**
* How the command uses the token — `redirect-out`/`redirect-in` for
* redirect destinations, `argument` otherwise. Only the protected-write
* gate reads this; the ordinary path gate is role-agnostic.
*/
readonly role: BashPathTokenRole;
/** The enclosing command's program name, or `null` outside a command. */
readonly program: string | null;
/**
* True inside a wrapper payload (`bash -c`, `eval`, `sudo`, `xargs`, …) or a
* command/process substitution — the enclosing program cannot vouch for the
* token's use.
*/
readonly wrapped: boolean;
}
/** Cd-aware path-rule candidates extracted from a bash program. */
export interface ResolvedBashPaths {
readonly ruleCandidates: readonly BashPathRuleCandidate[];
/**
* The protected-write slice: every `ruleCandidates` entry plus every
* `redirect-out` destination, which is admitted unconditionally — a redirect
* target need not exist nor look like a path to still be written (`cd dir
* && echo 1 > new.json`). Role metadata is what the gate classifies on.
*/
readonly writeCandidates: readonly BashPathRuleCandidate[];
}
/**
* Redirect targets that are really file-descriptor plumbing, not files:
* `>&1`, `2>&-` and friends write to a descriptor, so they never enter the
* write slice.
*/
const FD_DUPLICATE_TARGET = /^&?[\d-]+$/;
// ── Walk-time constants ──────────────────────────────────────────────────────
/** The working directory in force at the start of a program (`cwd`). */
@@ -101,9 +134,7 @@ export class BashPathResolver {
? CWD_BASE
: this.deriveBaseFromCdTarget(CWD_BASE, this.workdir);
const candidates = this.collectPathCandidates(rootNode, initialBase);
return {
ruleCandidates: this.projectRuleCandidates(candidates),
};
return this.projectCandidates(candidates);
}
// ── AST walk — collect PathCandidates ──────────────────────────────────
@@ -145,7 +176,7 @@ export class BashPathResolver {
case "redirected_statement":
return this.walkCurrentShellSequence(node, base, out);
case "command":
tagTokens(collectCommandTokens(node), base, out);
tagTokens(collectCommandTokensDetailed(node), base, out);
return this.foldCd(node, base);
case "pipeline":
// tree-sitter-bash mis-groups a redirect-bearing `&&`/`;` list as the
@@ -170,7 +201,7 @@ export class BashPathResolver {
// substitution interiors: collect every candidate in the subtree tagged
// with the enclosing base and do not fold their internal `cd`s. (Folding
// inside substitutions is deferred — conservative, never under-flags.)
tagTokens(collectPathCandidateTokens(node), base, out);
tagTokens(collectPathCandidateTokensDetailed(node), base, out);
return base;
}
}
@@ -230,7 +261,7 @@ export class BashPathResolver {
}
// Downstream stage (after a `|`): subshell — collect against the folded
// base, do not fold.
tagTokens(collectPathCandidateTokens(child), current, out);
tagTokens(collectPathCandidateTokensDetailed(child), current, out);
}
return current;
}
@@ -260,7 +291,7 @@ export class BashPathResolver {
if (child.type === "file_redirect") {
// Redirect destinations are part of the piped stage; collect them
// against the folded base without folding.
tagTokens(collectRedirectTokens(child), current, out);
tagTokens(collectRedirectTokensDetailed(child), current, out);
continue;
}
// The inner statement is the `list`/`command` being redirected; fold its
@@ -270,7 +301,7 @@ export class BashPathResolver {
return current;
}
// Bare `command` or any other shape: a true subshell first stage.
tagTokens(collectPathCandidateTokens(node), base, out);
tagTokens(collectPathCandidateTokensDetailed(node), base, out);
return base;
}
@@ -298,7 +329,7 @@ export class BashPathResolver {
current = this.walkForCandidates(child, current, out);
} else {
// Terminal child = the real pipe stage; collect without folding.
tagTokens(collectPathCandidateTokens(child), current, out);
tagTokens(collectPathCandidateTokensDetailed(child), current, out);
}
}
return current;
@@ -363,34 +394,67 @@ export class BashPathResolver {
/**
* Project candidates into policy lookup values while preserving literal
* values after a non-literal `cd`, where no reliable absolute base exists.
*
* `ruleCandidates` keeps the long-standing admission rule — path-shaped
* tokens plus bare tokens an existence probe confirms. `writeCandidates`
* additionally admits every `redirect-out` destination: a redirect writes
* its target whether or not the file exists yet, so existence and shape
* cannot gate it. The write slice is deduplicated per role so the same
* path appearing as both a read argument and a write destination keeps
* both classifications.
*/
private projectRuleCandidates(
private projectCandidates(
candidates: readonly PathCandidate[],
): BashPathRuleCandidate[] {
const seen = new Set<string>();
const result: BashPathRuleCandidate[] = [];
): ResolvedBashPaths {
const seenRules = new Set<string>();
const seenWrites = new Set<string>();
const ruleCandidates: BashPathRuleCandidate[] = [];
const writeCandidates: BashPathRuleCandidate[] = [];
for (const { token, base } of candidates) {
const pushWrite = (
candidate: BashPathRuleCandidate,
matchValues: readonly string[],
): void => {
const key = `${candidate.role}${matchValues.join("")}`;
if (seenWrites.has(key)) return;
seenWrites.add(key);
writeCandidates.push(candidate);
};
for (const candidate of candidates) {
const { token, base, role, program, wrapped } = candidate;
const shaped = classifyTokenAsRuleCandidate(
token,
this.normalizer.flavor,
);
const candidate =
const resolved =
shaped === null
? this.probeBareToken(token, base)
? this.probeBareToken(token, base, { role, program, wrapped })
: { token: shaped, path: this.buildRuleCandidatePath(shaped, base) };
if (!candidate) continue;
const matchValues = candidate.path.matchValues();
if (matchValues.length === 0) continue;
const key = matchValues.join("\0");
if (seen.has(key)) continue;
seen.add(key);
result.push(candidate);
if (resolved) {
const matchValues = resolved.path.matchValues();
if (matchValues.length === 0) continue;
const projected = { ...resolved, role, program, wrapped };
const key = matchValues.join("");
if (!seenRules.has(key)) {
seenRules.add(key);
ruleCandidates.push(projected);
}
pushWrite(projected, matchValues);
continue;
}
// A redirect-out destination that fails the shape/probe admission is
// still written — admit it to the write slice unconditionally (#393's
// literal-only fallback covers the unknown-base case).
if (role === "redirect-out" && !FD_DUPLICATE_TARGET.test(token)) {
const path = this.buildRuleCandidatePath(token, base);
const matchValues = path.matchValues();
if (matchValues.length === 0) continue;
pushWrite({ token, path, role, program, wrapped }, matchValues);
}
}
return result;
return { ruleCandidates, writeCandidates };
}
/**
@@ -414,6 +478,11 @@ export class BashPathResolver {
private probeBareToken(
token: string,
base: EffectiveBase,
meta: {
role: BashPathTokenRole;
program: string | null;
wrapped: boolean;
},
): BashPathRuleCandidate | null {
const bare = classifyBareTokenCandidate(token);
if (bare === null) return null;
@@ -424,7 +493,7 @@ export class BashPathResolver {
});
const lexical = path.value();
if (!lexical || !this.normalizer.entryExists(lexical)) return null;
return { token: bare, path };
return { token: bare, path, ...meta };
}
private buildRuleCandidatePath(
@@ -474,11 +543,19 @@ function isBackgrounded(seqNode: TSNode, index: number): boolean {
}
function tagTokens(
tokens: readonly string[],
tokens: readonly CollectedPathToken[],
base: EffectiveBase,
out: PathCandidate[],
): void {
for (const token of tokens) out.push({ token, base });
for (const token of tokens) {
out.push({
token: token.text,
base,
role: token.role,
program: token.program,
wrapped: token.wrapped,
});
}
}
/**
@@ -176,8 +176,11 @@ function makeCommandUnit(
* A leading `variable_assignment` prefix is skipped (matching
* `commandUnitText`), so offsets are relative to the `command_name`. An empty
* list means a pure assignment with no `command_name`.
*
* Exported for the token collector, which runs the same wrapper classification
* to mark wrapper payloads as wrapped path candidates.
*/
function readCommandWords(node: TSNode): CommandWord[] {
export function readCommandWords(node: TSNode): CommandWord[] {
const words: CommandWord[] = [];
let unitStart: number | undefined;
for (let i = 0; i < node.childCount; i++) {
@@ -26,6 +26,7 @@ export class BashProgram {
private readonly sourceCommand: string,
private readonly commandUnits: readonly BashCommand[],
private readonly resolvedRuleCandidates: readonly BashPathRuleCandidate[],
private readonly resolvedWriteCandidates: readonly BashPathRuleCandidate[],
) {}
/**
@@ -52,10 +53,10 @@ export class BashProgram {
): Promise<BashProgram> {
const parser = await getParser();
const tree = parser.parse(command);
if (!tree) return new BashProgram(command, [], []);
if (!tree) return new BashProgram(command, [], [], []);
try {
const { ruleCandidates } = new BashPathResolver(
const { ruleCandidates, writeCandidates } = new BashPathResolver(
normalizer,
options?.workdir,
).resolve(tree.rootNode);
@@ -63,6 +64,7 @@ export class BashProgram {
command,
collectCommands(tree.rootNode),
ruleCandidates,
writeCandidates,
);
} finally {
tree.delete();
@@ -112,4 +114,14 @@ export class BashProgram {
pathRuleCandidates(): BashPathRuleCandidate[] {
return [...this.resolvedRuleCandidates];
}
/**
* The protected-write slice: all path-rule candidates plus every
* `redirect-out` destination (admitted unconditionally), each carrying its
* `role`/`program`/`wrapped` metadata for the protected-write gate's
* read-vs-write classification.
*/
writeCandidates(): BashPathRuleCandidate[] {
return [...this.resolvedWriteCandidates];
}
}
@@ -1,4 +1,5 @@
import { basename } from "node:path";
import { readCommandWords } from "#src/access-intent/bash/command-enumeration";
import {
EXECUTION_HOST_TYPES,
forEachNestedExecution,
@@ -10,6 +11,50 @@ import {
SKIP_SUBTREE_TYPES,
} from "#src/access-intent/bash/node-text";
import type { TSNode } from "#src/access-intent/bash/parser";
import { classifyWrapperWords } from "#src/access-intent/bash/wrapper-analysis";
// ── Token roles and context ─────────────────────────────────────────────────
/**
* How the enclosing command uses a collected token.
*
* `redirect-out` covers every operator that writes a file (`>`, `>>`, `&>`,
* `&>>`, `>|`, `<>`, `>&`); `redirect-in` is a `<` read; `argument` is any
* other candidate — the protected-write gate decides read vs write from the
* program name.
*/
export type BashPathTokenRole = "argument" | "redirect-out" | "redirect-in";
/**
* A collected token together with the metadata the protected-write gate
* needs: the enclosing command's program name (after stripping env
* assignments), how the command uses it, and whether it sits inside a wrapper
* payload (`bash -c`, `eval`, `sudo`, `xargs`, …) or a nested execution
* context (command/process substitution).
*/
export interface CollectedPathToken {
readonly text: string;
readonly role: BashPathTokenRole;
readonly program: string | null;
readonly wrapped: boolean;
}
/**
* Enclosing-context facts carried down the token walk.
* `program` is the `command_name` of the enclosing command unit — `null` for
* tokens outside any command (e.g. a redirect on a subshell). `wrapped`
* becomes true inside a nested execution context and inside a wrapper
* command's payload.
*/
export interface TokenCollectionContext {
readonly program: string | null;
readonly wrapped: boolean;
}
const TOP_LEVEL_CONTEXT: TokenCollectionContext = {
program: null,
wrapped: false,
};
// ── Public surface ─────────────────────────────────────────────────────────
@@ -30,17 +75,33 @@ import type { TSNode } from "#src/access-intent/bash/parser";
* arguments generically.
*/
export function collectPathCandidateTokens(node: TSNode): string[] {
if (node.type === "command") return collectCommandTokens(node);
if (node.type === "file_redirect") return collectRedirectTokens(node);
return collectPathCandidateTokensDetailed(node).map((token) => token.text);
}
/**
* The metadata-carrying form of {@link collectPathCandidateTokens}; every
* token keeps its role, enclosing program, and wrapped flag for the
* protected-write write classification.
*/
export function collectPathCandidateTokensDetailed(
node: TSNode,
context: TokenCollectionContext = TOP_LEVEL_CONTEXT,
): CollectedPathToken[] {
if (node.type === "command")
return collectCommandTokensDetailed(node, context);
if (node.type === "file_redirect")
return collectRedirectTokensDetailed(node, context);
if (EXECUTION_HOST_TYPES.has(node.type)) {
return collectHostedExecutionTokens(node);
return collectHostedExecutionTokens(node, context);
}
if (SKIP_SUBTREE_TYPES.has(node.type)) return [];
const tokens: string[] = [];
const tokens: CollectedPathToken[] = [];
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) tokens.push(...collectPathCandidateTokens(child));
if (child) {
tokens.push(...collectPathCandidateTokensDetailed(child, context));
}
}
return tokens;
}
@@ -51,14 +112,41 @@ export function collectPathCandidateTokens(node: TSNode): string[] {
* `collectGenericCommandTokens`.
*/
export function collectCommandTokens(node: TSNode): string[] {
const commandName = extractCommandName(node);
const config = commandName
? PATTERN_FIRST_COMMANDS.get(commandName)
: undefined;
return collectCommandTokensDetailed(node).map((token) => token.text);
}
/**
* The metadata-carrying form of {@link collectCommandTokens}. The command's
* own name becomes the tokens' `program`, and the whole argument set is
* `wrapped` when the command is itself a wrapper unit (`bash -c`, `eval`,
* `sudo`, `xargs`, …) or sits inside a nested execution context.
*/
export function collectCommandTokensDetailed(
node: TSNode,
context: TokenCollectionContext = TOP_LEVEL_CONTEXT,
): CollectedPathToken[] {
const program = extractCommandName(node) ?? null;
const commandContext: TokenCollectionContext = {
program,
wrapped:
context.wrapped ||
classifyWrapperWords(readCommandWords(node)) !== undefined,
};
const config = program ? PATTERN_FIRST_COMMANDS.get(program) : undefined;
const tokens = config
? collectPatternCommandTokens(node, config)
: collectGenericCommandTokens(node);
return [...tokens, ...collectEmbeddedOptionValues(node)];
? collectPatternCommandTokens(node, config, commandContext)
: collectGenericCommandTokens(node, commandContext);
return [
...tokens,
...collectEmbeddedOptionValues(node).map((text) => ({
text,
role: "argument" as const,
program,
// A value hidden inside `--flag=PATH` is indirection — the read-only
// program allowance must not extend to it.
wrapped: true,
})),
];
}
/**
@@ -74,18 +162,54 @@ export function collectCommandTokens(node: TSNode): string[] {
* argument node.
*/
export function collectRedirectTokens(node: TSNode): string[] {
const tokens: string[] = [];
return collectRedirectTokensDetailed(node).map((token) => token.text);
}
/**
* The metadata-carrying form of {@link collectRedirectTokens}. The redirect
* operator decides the role: an operator containing `>` writes
* (`redirect-out`), `<` reads (`redirect-in`); when `node` is not a
* `file_redirect` the tokens keep the neutral `argument` role.
*/
export function collectRedirectTokensDetailed(
node: TSNode,
context: TokenCollectionContext = TOP_LEVEL_CONTEXT,
): CollectedPathToken[] {
const role =
node.type === "file_redirect" ? redirectRole(node) : "argument";
const tokens: CollectedPathToken[] = [];
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child) continue;
if (ARG_NODE_TYPES.has(child.type)) {
tokens.push(resolveNodeText(child));
tokens.push({
text: resolveNodeText(child),
role,
program: context.program,
wrapped: context.wrapped,
});
}
tokens.push(...collectHostedExecutionTokens(child));
tokens.push(...collectHostedExecutionTokens(child, context));
}
return tokens;
}
/**
* The write/read role a `file_redirect` node's operator implies: any operator
* containing `>` (`>`, `>>`, `&>`, `&>>`, `>|`, `<>`, `>&`) writes;
* `<` reads. A missing operator defaults to the write role — the
* conservative answer for the protected-write slice.
*/
function redirectRole(node: TSNode): "redirect-out" | "redirect-in" {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (!child || child.isNamed) continue;
if (child.text.includes(">")) return "redirect-out";
if (child.text.includes("<")) return "redirect-in";
}
return "redirect-out";
}
/**
* Collect the path-candidate tokens of every command nested inside `node`'s
* execution contexts, reading none of the host subtree's own text.
@@ -95,15 +219,27 @@ export function collectRedirectTokens(node: TSNode): string[] {
*
* `node` may be a context outright (`> $(cmd)`) or merely contain one
* (`> ${DIR}/$(cmd)`); `forEachNestedExecution` searches strictly within a
* subtree, so the first case is checked here.
* subtree, so the first case is checked here. Everything found inside is
* `wrapped` — the enclosing program cannot vouch for what a substitution runs.
*/
function collectHostedExecutionTokens(node: TSNode): string[] {
function collectHostedExecutionTokens(
node: TSNode,
context: TokenCollectionContext,
): CollectedPathToken[] {
if (NESTED_EXECUTION_CONTEXTS.has(node.type)) {
return collectPathCandidateTokens(node);
return collectPathCandidateTokensDetailed(node, {
...context,
wrapped: true,
});
}
const tokens: string[] = [];
const tokens: CollectedPathToken[] = [];
forEachNestedExecution(node, (contextNode) => {
tokens.push(...collectPathCandidateTokens(contextNode));
tokens.push(
...collectPathCandidateTokensDetailed(contextNode, {
...context,
wrapped: true,
}),
);
});
return tokens;
}
@@ -187,7 +323,10 @@ const PATTERN_FIRST_COMMANDS: ReadonlyMap<string, PatternCommandConfig> =
[
"sed",
{
argConsumingFlags: new Set(["-e", "-i"]),
// `-i` is deliberately absent: GNU sed's in-place flag takes an
// attached suffix, never a separate argument, so treating it as
// arg-consuming would swallow the script and hide the file operand.
argConsumingFlags: new Set(["-e"]),
fileConsumingFlags: new Set(["-f"]),
},
],
@@ -322,13 +461,23 @@ function classifyPatternCommandFlag(
function collectPatternCommandTokens(
node: TSNode,
config: PatternCommandConfig,
): string[] {
context: TokenCollectionContext,
): CollectedPathToken[] {
const patternPositionals = config.patternPositionals ?? 1;
let hasExplicitScript = false;
let positionalsSeen = 0;
let nextArgAction: "skip" | "extract" | null = null;
let pastEndOfFlags = false;
const tokens: string[] = [];
const tokens: CollectedPathToken[] = [];
const pushArgument = (text: string): void => {
tokens.push({
text,
role: "argument",
program: context.program,
wrapped: context.wrapped,
});
};
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
@@ -341,7 +490,7 @@ function collectPatternCommandTokens(
// Only process argument-like nodes; recurse into others
// (e.g. command_substitution) for nested commands.
if (!ARG_NODE_TYPES.has(child.type)) {
tokens.push(...collectPathCandidateTokens(child));
tokens.push(...collectPathCandidateTokensDetailed(child, context));
continue;
}
@@ -353,7 +502,7 @@ function collectPatternCommandTokens(
continue;
}
if (nextArgAction === "extract") {
tokens.push(text);
pushArgument(text);
nextArgAction = null;
continue;
}
@@ -387,7 +536,7 @@ function collectPatternCommandTokens(
}
// File argument — collect as path candidate.
tokens.push(text);
pushArgument(text);
}
return tokens;
@@ -397,8 +546,11 @@ function collectPatternCommandTokens(
* Collect all argument tokens from a generic (non-pattern-first) command node,
* skipping the command name and variable assignments.
*/
function collectGenericCommandTokens(node: TSNode): string[] {
const tokens: string[] = [];
function collectGenericCommandTokens(
node: TSNode,
context: TokenCollectionContext,
): CollectedPathToken[] {
const tokens: CollectedPathToken[] = [];
let seenCommandName = false;
for (let i = 0; i < node.childCount; i++) {
@@ -421,12 +573,17 @@ function collectGenericCommandTokens(node: TSNode): string[] {
// Argument nodes: resolve their text and collect.
if (ARG_NODE_TYPES.has(child.type)) {
tokens.push(resolveNodeText(child));
tokens.push({
text: resolveNodeText(child),
role: "argument",
program: context.program,
wrapped: context.wrapped,
});
continue;
}
// Recurse into other children (e.g. command_substitution nested in args)
tokens.push(...collectPathCandidateTokens(child));
tokens.push(...collectPathCandidateTokensDetailed(child, context));
}
return tokens;
@@ -6,9 +6,11 @@ import {
type NamedAuthorizer,
type SelectedAuthority,
selectAuthorizer,
type TerminalAuthorizer,
} from "./authorizer";
import { composeAuthorizerChain } from "./authorizer-chain";
import type { AuthorizerLookup } from "./authorizer-registry";
import { LocalUserAuthorizer } from "./local-user-authorizer";
import type {
PermissionPrompterApi,
PromptPermissionDetails,
@@ -172,6 +174,27 @@ export class AuthorizerSelection
new Error("escalate called before the session was activated"),
);
}
// A protected-write ask is human-only: it composes with zero links (no
// AutoReview), and a terminal that is not the local UI — a denying node,
// a `ParentAuthorizer` relay, or a session with no UI — cannot confirm
// it, so it denies without prompting.
if (details.humanOnly) {
if (!(authority.terminal instanceof LocalUserAuthorizer)) {
return this.deps.prompter.prompt(
new ProtectedWriteUnavailableAuthorizer(),
details,
);
}
return this.deps.prompter.prompt(
composeAuthorizerChain(
[],
authority.terminal,
this.deps.getPermissionQuery(),
this.deps.logger,
),
details,
);
}
const chain = composeAuthorizerChain(
this.linksFor(authority, details.requestId),
authority.terminal,
@@ -181,3 +204,26 @@ export class AuthorizerSelection
return this.deps.prompter.prompt(chain, details);
}
}
/**
* The human-only terminal for a protected-write ask on a node with no local
* UI: answers with a fixed denial, marked `confirmationUnavailable` so the
* review log and decision event record that no human ever ruled rather than
* a user denial (#719). Routed through `PermissionPrompter` so the
* waiting/denied review entries are written like any other escalated ask.
*/
class ProtectedWriteUnavailableAuthorizer implements TerminalAuthorizer {
authorize(): Promise<PermissionPromptDecision> {
return Promise.resolve({
approved: false,
state: "denied_with_reason",
denialReason:
"写入受保护文件需要人工确认,但当前没有可用的确认界面",
confirmationUnavailable: true,
decidedBy: {
kind: "unavailable",
reason: "protected_write_no_local_ui",
},
});
}
}
@@ -76,6 +76,11 @@ function buildRequestOptions(
const notes = details.deferNotes;
const withNotes =
notes && notes.length > 0 ? { deferNotes: notes } : undefined;
// A protected-write ask offers no session grant at all — a human must
// confirm every write, so the option is hidden rather than unrecorded.
if (details.humanOnly) {
return { hideSessionOption: true, ...withNotes };
}
if (details.forwarding && details.sessionApproval && pattern) {
return {
sessionScope: buildForwardedScopeLabels(
@@ -119,6 +119,12 @@ export interface RequestPermissionOptions {
* deterministic, code-authored sentence.
*/
deferNotes?: readonly PromptDecisionNote[];
/**
* Protected-write asks only: suppress the "for this session" option
* entirely — a human-only write must be confirmed every time, so the
* session grant is not offered rather than merely unrecorded.
*/
hideSessionOption?: boolean;
}
export async function requestPermissionDecisionFromUi(
@@ -130,7 +136,7 @@ export async function requestPermissionDecisionFromUi(
const sessionOption = options?.sessionLabel ?? APPROVE_FOR_SESSION_OPTION;
const decisionOptions = [
APPROVE_OPTION,
sessionOption,
...(options?.hideSessionOption ? [] : [sessionOption]),
DENY_OPTION,
DENY_WITH_REASON_OPTION,
] as const;
@@ -16,6 +16,7 @@ import {
type UnattributedDecision,
} from "#src/authority/permission-dialog";
import {
enabledPromptKeys,
initialPromptState,
type PromptEvent,
type PromptKey,
@@ -139,8 +140,6 @@ const OPTION_LABELS: Record<PromptKey, string> = {
r: "No, provide reason",
};
const OPTION_ORDER: readonly PromptKey[] = ["y", "s", "n", "r"];
export function presentInlinePermissionPrompt(
view: PermissionPromptView,
title: string,
@@ -151,6 +150,7 @@ export function presentInlinePermissionPrompt(
doublePressToConfirm: view.doublePressToConfirm,
sessionLabel: options?.sessionLabel ?? DEFAULT_SESSION_LABEL,
sessionScope: options?.sessionScope,
hideSessionOption: options?.hideSessionOption,
};
return view.ui.custom<UnattributedDecision>(
(tui, theme, keybindings, done) =>
@@ -341,7 +341,9 @@ class PermissionPromptComponent implements Component {
return { type: "cancel" };
}
if (this.state.step === "decision") {
const key = OPTION_ORDER.find((option) => matchesKey(data, option));
const key = enabledPromptKeys(this.config).find((option) =>
matchesKey(data, option),
);
if (key) {
return { type: "hotkey", key };
}
@@ -365,7 +367,7 @@ class PermissionPromptComponent implements Component {
private renderDecision(width: number): string[] {
const ask = this.renderAsk(width);
const lines = [this.theme.fg("accent", this.title), ...ask.lines, ""];
for (const key of OPTION_ORDER) {
for (const key of enabledPromptKeys(this.config)) {
const label = key === "s" ? this.config.sessionLabel : OPTION_LABELS[key];
const selected = this.state.highlightedKey === key;
const marker = selected ? "▶" : " ";
@@ -30,6 +30,21 @@ const OPTION_VERBS: Record<PromptKey, string> = {
r: "deny with a reason",
};
/**
* The keys this prompt offers: `OPTION_ORDER` minus `s` when
* {@link PromptModelConfig.hideSessionOption} suppresses the session grant
* (a protected-write ask must be confirmed every time). Shared by the
* reducer and the component renderer so the two never disagree about which
* options exist.
*/
export function enabledPromptKeys(
config: PromptModelConfig,
): readonly PromptKey[] {
return config.hideSessionOption
? OPTION_ORDER.filter((key) => key !== "s")
: OPTION_ORDER;
}
/** Static configuration for a single prompt presentation. */
export interface PromptModelConfig {
/** When true, a letter hotkey arms first and commits only on a second press. */
@@ -42,6 +57,11 @@ export interface PromptModelConfig {
* default) or the whole serving session.
*/
sessionScope?: NonNullable<RequestPermissionOptions["sessionScope"]>;
/**
* Protected-write asks only: suppress the `s` option entirely — a
* human-only write must be confirmed every time.
*/
hideSessionOption?: boolean;
}
/** The re-render view state the component draws from. */
@@ -116,11 +136,18 @@ function reduceDecisionStep(
case "nav":
return render({
...state,
highlightedKey: shiftKey(state.highlightedKey, event.direction),
highlightedKey: shiftKey(
state.highlightedKey,
event.direction,
enabledPromptKeys(config),
),
armedKey: undefined,
hint: "",
});
case "hotkey":
if (!enabledPromptKeys(config).includes(event.key)) {
return render(state);
}
return pressHotkey(config, state, event.key);
case "confirm":
return commit(config, state, state.highlightedKey);
@@ -245,11 +272,16 @@ function reduceScopeStep(
}
}
function shiftKey(current: PromptKey, direction: "up" | "down"): PromptKey {
const index = OPTION_ORDER.indexOf(current);
function shiftKey(
current: PromptKey,
direction: "up" | "down",
keys: readonly PromptKey[],
): PromptKey {
const index = keys.indexOf(current);
if (index === -1) return keys[0] ?? current;
const delta = direction === "down" ? 1 : -1;
const next = (index + delta + OPTION_ORDER.length) % OPTION_ORDER.length;
return OPTION_ORDER[next] ?? current;
const next = (index + delta + keys.length) % keys.length;
return keys[next] ?? current;
}
function render(state: PromptViewState): PromptOutcome {
@@ -79,6 +79,13 @@ export interface PromptPermissionDetails {
* model-generated), `system` is a code-authored sentence.
*/
deferNotes?: readonly DeferNote[];
/**
* Marks the protected-write escalation path: the ask must reach the local
* human terminal directly — no `authorizerChain` links (AutoReview), no
* session-grant option, and a terminal without a local UI denies without
* prompting. Set by `GateRunner` from the descriptor's `humanOnly` marker.
*/
humanOnly?: boolean;
}
/**
+9 -40
View File
@@ -1,11 +1,6 @@
import { existsSync, readFileSync } from "node:fs";
import { normalize } from "node:path";
import type { ZodError } from "zod";
import {
getGlobalConfigPath,
getLegacyExtensionConfigPath,
getLegacyGlobalPolicyPath,
} from "./config-paths";
import { getGlobalConfigPath } from "./config-paths";
import {
type ShellToolsConfig,
type UnifiedPermissionConfig,
@@ -285,47 +280,21 @@ export interface MergedConfigResult {
}
/**
* Load the user-global config, detect legacy global files, and collect issues.
* Project-level `policy.jsonc` files are intentionally not loaded.
* Load the global bundle policy config and collect issues.
*
* The installed bundle's `config/pi-permission-policy.jsonc` is the single
* source of truth. Project-level `policy.jsonc` files and leftover files at
* legacy locations are ignored — never merged and never migrated.
*/
export function loadAndMergeConfigs(
agentDir: string,
_cwd: string,
extensionRoot: string,
globalConfigPath: string = getGlobalConfigPath(),
): MergedConfigResult {
const allIssues: string[] = [];
const newGlobalPath = getGlobalConfigPath(agentDir);
const legacyGlobalPolicyPath = getLegacyGlobalPolicyPath(agentDir);
const legacyExtConfigPath = getLegacyExtensionConfigPath(extensionRoot);
let merged: UnifiedPermissionConfig = {};
if (existsSync(legacyGlobalPolicyPath)) {
const legacy = loadUnifiedConfig(legacyGlobalPolicyPath);
allIssues.push(
`Legacy global policy found at '${legacyGlobalPolicyPath}'. ` +
`Move it to '${newGlobalPath}':\n` +
` mv '${legacyGlobalPolicyPath}' '${newGlobalPath}'`,
);
merged = mergeUnifiedConfigs(merged, legacy.config);
}
if (
normalize(legacyExtConfigPath) !== normalize(newGlobalPath) &&
existsSync(legacyExtConfigPath)
) {
const legacy = loadUnifiedConfig(legacyExtConfigPath);
allIssues.push(
`Legacy extension config found at '${legacyExtConfigPath}'. ` +
`Move it to '${newGlobalPath}':\n` +
` mv '${legacyExtConfigPath}' '${newGlobalPath}'`,
);
merged = mergeUnifiedConfigs(merged, legacy.config);
}
const globalResult = loadUnifiedConfig(newGlobalPath);
const globalResult = loadUnifiedConfig(globalConfigPath);
allIssues.push(...globalResult.issues);
const globalConfig = globalResult.config;
merged = mergeUnifiedConfigs(merged, globalConfig);
const merged = mergeUnifiedConfigs({}, globalConfig);
const bashFallbackIssue = detectPermissiveBashFallback(merged.permission);
if (bashFallbackIssue) allIssues.push(bashFallbackIssue);
+1 -1
View File
@@ -30,7 +30,7 @@ const COMMAND_ARGUMENTS = [
{
value: "path",
label: "Show config path",
description: "Display the policy.jsonc path used by pi-permission-system",
description: "Display the pi-permission-policy.jsonc path used by pi-permission-system",
},
{
value: "reset",
+16 -21
View File
@@ -1,37 +1,41 @@
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const EXTENSION_ID = "pi-permission-system";
/**
* Bundle-owned config tree under the agent directory: install.sh / update.sh
* delete and rebuild `my-pi/` wholesale on every run, so policy, hard guards,
* and logs all live beneath it. Files at the former
* `extensions/pi-permission-system/` location are ignored — never migrated.
* Bundle-level `config/` is the single source of truth for policy and hard
* guards: paths resolve relative to this source file so the installed bundle
* is read directly, with no deployment, seeding, or migration into the agent
* dir. Only disposable runtime state (logs) stays under `<agentDir>/my-pi/`.
*/
const BUNDLE_CONFIG_DIR = "my-pi";
const BUNDLE_CONFIG_DIR = fileURLToPath(new URL("../../config", import.meta.url));
const BUNDLE_MANAGED_DIR = "my-pi";
const BUNDLE_PERMISSION_DIR = "permission-system";
export const DEBUG_LOG_FILENAME = `${EXTENSION_ID}-debug.jsonl`;
export const REVIEW_LOG_FILENAME = `${EXTENSION_ID}-permission-review.jsonl`;
/**
* Bundle-owned runtime directory under the agent dir — logs only. Stale files
* left here from earlier deployments are ignored, never migrated.
*/
export function getGlobalConfigDir(agentDir: string): string {
return join(agentDir, BUNDLE_CONFIG_DIR, BUNDLE_PERMISSION_DIR);
return join(agentDir, BUNDLE_MANAGED_DIR, BUNDLE_PERMISSION_DIR);
}
export function getGlobalConfigPath(agentDir: string): string {
return join(getGlobalConfigDir(agentDir), "policy.jsonc");
export function getGlobalConfigPath(): string {
return join(BUNDLE_CONFIG_DIR, "pi-permission-policy.jsonc");
}
export function getHardGuardConfigPath(agentDir: string): string {
return join(getGlobalConfigDir(agentDir), "hard-guards.jsonc");
export function getHardGuardConfigPath(): string {
return join(BUNDLE_CONFIG_DIR, "pi-permission-hard-guards.jsonc");
}
export function getGlobalLogsDir(agentDir: string): string {
return join(getGlobalConfigDir(agentDir), "logs");
}
/**
* Directory holding project-scoped custom agent definition files.
*
@@ -44,12 +48,3 @@ export function getGlobalLogsDir(agentDir: string): string {
export function getProjectAgentsDir(cwd: string): string {
return join(cwd, ".pi", "agents");
}
export function getLegacyGlobalPolicyPath(agentDir: string): string {
return join(agentDir, "pi-permissions.jsonc");
}
export function getLegacyExtensionConfigPath(extensionRoot: string): string {
return join(extensionRoot, "config.json");
}
+2 -18
View File
@@ -1,29 +1,13 @@
import type { ResolvedPolicyPaths } from "./permission-manager";
export interface ResolvedConfigLogEntry {
globalConfigPath: string;
globalConfigExists: boolean;
agentsDir: string;
agentsDirExists: boolean;
projectAgentsDir: string | null;
projectAgentsDirExists: boolean;
legacyGlobalPolicyDetected: boolean;
legacyExtensionConfigDetected: boolean;
}
export type ResolvedConfigLogEntry = ResolvedPolicyPaths;
export interface BuildResolvedConfigLogEntryOptions {
policyPaths: ResolvedPolicyPaths;
legacyGlobalPolicyDetected?: boolean;
legacyExtensionConfigDetected?: boolean;
}
export function buildResolvedConfigLogEntry(
options: BuildResolvedConfigLogEntryOptions,
): ResolvedConfigLogEntry {
return {
...options.policyPaths,
legacyGlobalPolicyDetected: options.legacyGlobalPolicyDetected ?? false,
legacyExtensionConfigDetected:
options.legacyExtensionConfigDetected ?? false,
};
return { ...options.policyPaths };
}
+1 -1
View File
@@ -262,7 +262,7 @@ export const unifiedConfigSchema = z
description:
"Ordinary JSONC policy combining runtime knobs, AutoReview settings, and flat permission rules for pi-permission-system.",
markdownDescription:
"Global ordinary JSONC policy for [pi-permission-system](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system).\n\nPlace at `~/.pi/agent/my-pi/permission-system/policy.jsonc`. Project-level policy files are not loaded. Global-only hard guards use the separate `hard-guards.jsonc` schema.",
"Global ordinary JSONC policy for [pi-permission-system](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-system).\n\nRead directly from the installed bundle's `config/pi-permission-policy.jsonc`. Project-level policy files are not loaded. Global-only hard guards use the separate `config/pi-permission-hard-guards.jsonc` schema.",
});
/** A permission decision. */
+7 -28
View File
@@ -5,22 +5,17 @@ import {
unlinkSync,
writeFileSync,
} from "node:fs";
import { dirname, normalize } from "node:path";
import { dirname } from "node:path";
import type {
ExtensionCommandContext,
ExtensionContext,
} from "@earendil-works/pi-coding-agent";
import { loadAndMergeConfigs, loadUnifiedConfig } from "./config-loader";
import {
getGlobalConfigPath,
getLegacyExtensionConfigPath,
getLegacyGlobalPolicyPath,
} from "./config-paths";
import { getGlobalConfigPath } from "./config-paths";
import { buildResolvedConfigLogEntry } from "./config-reporter";
import {
DEFAULT_EXTENSION_CONFIG,
EXTENSION_ROOT,
normalizePermissionSystemConfig,
type PermissionSystemExtensionConfig,
} from "./extension-config";
@@ -66,6 +61,8 @@ export interface ConfigStoreDeps {
agentDir: string;
policyPaths: ResolvedPolicyPathProvider;
logger: DebugReviewLogger;
/** Override the policy file `save()` writes — tests inject a temp path. */
configPath?: string;
}
/**
@@ -98,12 +95,7 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
* for lifecycle logging and active-agent trust decisions owned elsewhere.
*/
refresh(ctx: ExtensionContext | undefined, projectTrusted: boolean): void {
const cwd = ctx?.cwd ?? null;
const mergeResult = loadAndMergeConfigs(
this.deps.agentDir,
cwd ?? "",
EXTENSION_ROOT,
);
const mergeResult = loadAndMergeConfigs(this.deps.configPath);
const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
this.config = runtimeConfig;
@@ -143,7 +135,7 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
ctx: ExtensionCommandContext,
): void {
const normalized = normalizePermissionSystemConfig(next);
const globalPath = getGlobalConfigPath(this.deps.agentDir);
const globalPath = this.deps.configPath ?? getGlobalConfigPath();
const existing = loadUnifiedConfig(globalPath);
const merged = {
@@ -192,20 +184,7 @@ export class ConfigStore implements SessionConfigStore, CommandConfigStore {
*/
logResolvedPaths(_cwd?: string): void {
const policyPaths = this.deps.policyPaths.getResolvedPolicyPaths();
const { agentDir } = this.deps;
const legacyGlobalPolicyDetected = existsSync(
getLegacyGlobalPolicyPath(agentDir),
);
const legacyExtConfigPath = getLegacyExtensionConfigPath(EXTENSION_ROOT);
const newGlobalPath = getGlobalConfigPath(agentDir);
const legacyExtensionConfigDetected =
normalize(legacyExtConfigPath) !== normalize(newGlobalPath) &&
existsSync(legacyExtConfigPath);
const entry = buildResolvedConfigLogEntry({
policyPaths,
legacyGlobalPolicyDetected,
legacyExtensionConfigDetected,
});
const entry = buildResolvedConfigLogEntry({ policyPaths });
this.deps.logger.review(
"config.resolved",
entry as unknown as Record<string, unknown>,
@@ -1,6 +1,4 @@
import { mkdirSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
normalizeAutoReviewConfig,
type AutoReviewConfig,
@@ -44,12 +42,6 @@ export const DEFAULT_EXTENSION_CONFIG: PermissionSystemExtensionConfig = {
doublePressToConfirm: true,
};
function resolveExtensionRoot(moduleUrl = import.meta.url): string {
return join(dirname(fileURLToPath(moduleUrl)), "..");
}
export const EXTENSION_ROOT = resolveExtensionRoot();
const PERMISSION_POLICY_KEYS: ReadonlySet<string> = new Set([
"defaultPolicy",
"tools",
@@ -7,6 +7,22 @@ import type { PermissionCheckResult, PermissionState } from "#src/types";
// ── Descriptor types ───────────────────────────────────────────────────────
/**
* The protected-write match a `humanOnly` descriptor carries: the first
* protected target's display facts plus how many additional protected targets
* the same call writes.
*/
export interface ProtectedWriteTarget {
/** First protected target's display path. */
readonly path: string;
/** The `protectedWrites` pattern that matched it. */
readonly pattern: string;
/** The pattern's human-facing label. */
readonly label: string;
/** How many additional protected targets the same call writes. */
readonly extraCount: number;
}
/**
* Pure output of a gate function — describes what to check and how to present it.
*
@@ -33,6 +49,15 @@ export interface GateDescriptor {
* interface — the runner never needs to know which case applies.
*/
sessionApproval?: SessionApproval;
/**
* Set only by the protected-write gate: the write must reach the local
* human prompt directly — the runner skips the session-approval and yolo
* fast paths, the escalation bypasses `authorizerChain` links (AutoReview),
* no session-grant option is offered, and a terminal without a local UI
* denies without prompting. Carries the first protected target's facts for
* the system note and review log.
*/
humanOnly?: ProtectedWriteTarget;
/**
* Details passed to the interactive permission prompt.
*
@@ -23,6 +23,7 @@ import {
deriveResolution,
resolveYoloGrant,
} from "./helpers";
import { protectedWriteDeferNote } from "./protected-write";
import type { GateOutcome } from "./types";
// ── GateRunner class ───────────────────────────────────────────────────────
@@ -129,8 +130,13 @@ export class GateRunner {
// context holds what every resolution of this gate shares, and who decided
// is by definition not shared (#726).
// A protected-write descriptor never takes the session or yolo fast path:
// the write reaches a human prompt even when a session grant covers the
// path or yolo mode is on.
const humanOnly = descriptor.humanOnly;
// 2. Session-hit fast path
if (check.source === "session") {
if (!humanOnly && check.source === "session") {
this.reporter.writeReviewLog("permission_request.session_approved", {
...logContext,
resolution: "session_approved",
@@ -158,7 +164,9 @@ export class GateRunner {
// "yolo" on the matched rule, #526) or, under yolo, an ask synthesized
// after resolution (#712). Auto-approve without prompting, preserving the
// single auto_approved review entry + decision event so log parity holds.
const yoloGrant = resolveYoloGrant(check, this.isYoloEnabled());
const yoloGrant = humanOnly
? undefined
: resolveYoloGrant(check, this.isYoloEnabled());
if (yoloGrant) {
this.reporter.writeReviewLog("permission_request.auto_approved", {
...logContext,
@@ -208,6 +216,12 @@ export class GateRunner {
...(descriptor.sessionApproval
? { sessionApproval: descriptor.sessionApproval.toForwardedData() }
: {}),
...(humanOnly
? {
humanOnly: true,
deferNotes: [protectedWriteDeferNote(humanOnly)],
}
: {}),
});
autoApproved = decision.autoApproved === true;
confirmationUnavailable = decision.confirmationUnavailable === true;
@@ -6,6 +6,7 @@ import {
type ShellInvocation,
} from "#src/access-intent/tool-kind";
import type { ShellToolsConfig } from "#src/config-schema";
import type { ProtectedWriteRule } from "#src/hard-guard-loader";
import type { PathNormalizer } from "#src/path-normalizer";
import type { ScopedPermissionResolver } from "#src/permission-resolver";
import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
@@ -20,6 +21,7 @@ import { resolveBashCommandCheck } from "./bash-command";
import { describeBashPathGate } from "./bash-path";
import type { GateResult } from "./descriptor";
import { describePathGate } from "./path";
import { describeProtectedWriteGate } from "./protected-write";
import type { GateRunner } from "./runner";
import { describeSkillReadGate } from "./skill-read";
import { describeToolGate } from "./tool";
@@ -67,6 +69,8 @@ export class ToolCallGatePipeline {
private readonly inputs: ToolCallGateInputs,
private readonly customFormatters?: ToolInputFormatterLookup,
private readonly customExtractors?: ToolAccessExtractorLookup,
private readonly getProtectedWrites: () => readonly ProtectedWriteRule[] = () =>
[],
) {}
async evaluate(
@@ -119,6 +123,16 @@ export class ToolCallGatePipeline {
toolDescriptor.preCheck = toolCheck;
return toolDescriptor;
},
// 5. Protected-write gate — last so ordinary/hard-guard denies from the
// producers above block first; its descriptor escalates to the human.
() =>
describeProtectedWriteGate(
tcc,
bashProgram,
shell,
normalizer,
this.getProtectedWrites(),
),
];
for (const produce of gateProducers) {
+92 -28
View File
@@ -1,16 +1,72 @@
import { existsSync, readFileSync, statSync } from "node:fs";
import { dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import {
normalizeFlatPermissionValue,
stripJsonComments,
} from "./config-loader";
import { expandHomePath } from "./expand-home";
import type { PolicyLoader, ResolvedPolicyPaths } from "./policy-loader";
import type { FlatPermissionConfig, PatternValue, ScopeConfig } from "./types";
interface HardGuardDocument {
version?: number;
permission?: unknown;
protectedWrites?: unknown;
}
/**
* One entry of the hard-guard file's `protectedWrites` array.
*
* `pattern` uses the same wildcard syntax as the `path` surface; `{agentDir}`,
* `{configHome}` and a leading `~/` are expanded at load time. `label` is the
* human-facing name shown by the protected-write prompt and review log.
*/
export interface ProtectedWriteRule {
readonly pattern: string;
readonly label: string;
}
function configHome(): string {
const xdg = process.env.XDG_CONFIG_HOME;
return xdg && xdg.trim() !== "" ? xdg : join(homedir(), ".config");
}
function expandProtectedWritePattern(pattern: string): string {
return expandHomePath(
pattern
.replaceAll("{agentDir}", getAgentDir())
.replaceAll("{configHome}", configHome()),
);
}
function parseProtectedWrites(value: unknown): ProtectedWriteRule[] {
if (value === undefined) return [];
if (!Array.isArray(value)) {
throw new Error("hard guard 'protectedWrites' must be an array");
}
return value.map((entry, index) => {
const record =
typeof entry === "object" && entry !== null
? (entry as Record<string, unknown>)
: undefined;
if (
!record ||
typeof record.pattern !== "string" ||
record.pattern.trim() === "" ||
typeof record.label !== "string" ||
record.label.trim() === ""
) {
throw new Error(
`hard guard 'protectedWrites[${index}]' must be { "pattern": string, "label": string }`,
);
}
return {
pattern: expandProtectedWritePattern(record.pattern),
label: record.label,
};
});
}
const EMPTY_SCOPE: ScopeConfig = {};
@@ -38,7 +94,10 @@ function assertHardGuardValues(permission: FlatPermissionConfig): void {
}
}
function readHardGuards(path: string): ScopeConfig {
function readHardGuards(path: string): {
scope: ScopeConfig;
protectedWrites: ProtectedWriteRule[];
} {
const parsed = JSON.parse(stripJsonComments(readFileSync(path, "utf8"))) as HardGuardDocument;
if (parsed.version !== undefined && parsed.version !== 1) {
throw new Error(`unsupported hard guard version: ${String(parsed.version)}`);
@@ -48,24 +107,23 @@ function readHardGuards(path: string): ScopeConfig {
throw new Error("hard guard permission must be an object");
}
assertHardGuardValues(normalized);
return { permission: { "*": "allow", ...normalized } };
return {
scope: { permission: { "*": "allow", ...normalized } },
protectedWrites: parseProtectedWrites(parsed.protectedWrites),
};
}
/**
* Global-only hard boundary: deny rules plus same-file allow exceptions. It keeps the last valid snapshot and
* falls back to the bundled defaults; if both are invalid it denies all.
* Global-only hard boundary: deny rules plus same-file allow exceptions. It keeps the last valid snapshot;
* when the bundle config file is missing or invalid it fails closed to deny-all.
*/
export class HardGuardPolicyLoader implements PolicyLoader {
private lastStamp = "";
private lastGood: ScopeConfig | undefined;
private lastGoodProtectedWrites: ProtectedWriteRule[] = [];
private issues: string[] = [];
constructor(
private readonly userPath: string,
private readonly fallbackPath = fileURLToPath(
new URL("../config/hard-guards.default.jsonc", import.meta.url),
),
) {}
constructor(private readonly userPath: string) {}
loadGlobalConfig(): ScopeConfig {
const stamp = this.getCacheStamp();
@@ -73,15 +131,15 @@ export class HardGuardPolicyLoader implements PolicyLoader {
this.lastStamp = stamp;
this.issues = [];
for (const candidate of [this.userPath, this.fallbackPath]) {
if (!existsSync(candidate)) continue;
if (existsSync(this.userPath)) {
try {
const loaded = readHardGuards(candidate);
this.lastGood = loaded;
return loaded;
const loaded = readHardGuards(this.userPath);
this.lastGood = loaded.scope;
this.lastGoodProtectedWrites = loaded.protectedWrites;
return loaded.scope;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.issues.push(`Failed to load hard guards '${candidate}': ${message}`);
this.issues.push(`Failed to load hard guards '${this.userPath}': ${message}`);
}
}
@@ -90,21 +148,27 @@ export class HardGuardPolicyLoader implements PolicyLoader {
return DENY_ALL;
}
/**
* The `protectedWrites` snapshot of the last valid file (empty when the file
* was never valid — in which case every request is already denied). Reloads
* through the same mtime cache stamp as {@link loadGlobalConfig}.
*/
getProtectedWrites(): ProtectedWriteRule[] {
this.loadGlobalConfig();
return [...this.lastGoodProtectedWrites];
}
loadAgentConfig(): ScopeConfig { return EMPTY_SCOPE; }
loadProjectAgentConfig(): ScopeConfig { return EMPTY_SCOPE; }
getConfiguredMcpServerNames(): readonly string[] { return []; }
getCacheStamp(): string {
return [this.userPath, this.fallbackPath]
.map((path) => {
try {
const stat = statSync(path);
return `${path}:${stat.mtimeMs}:${stat.size}`;
} catch {
return `${path}:missing`;
}
})
.join("|");
try {
const stat = statSync(this.userPath);
return `${this.userPath}:${stat.mtimeMs}:${stat.size}`;
} catch {
return `${this.userPath}:missing`;
}
}
getConfigIssues(): string[] { return [...this.issues]; }
+13 -3
View File
@@ -55,8 +55,14 @@ import { SessionRules } from "./session-rules";
import { ToolAccessExtractorRegistry } from "./tool-access-extractor-registry";
import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry";
export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
export default function piPermissionSystemExtension(
pi: ExtensionAPI,
options?: { configPath?: string; hardGuardConfigPath?: string },
): void {
const agentDir = getAgentDir();
const globalConfigPath = options?.configPath ?? getGlobalConfigPath();
const hardGuardConfigPath =
options?.hardGuardConfigPath ?? getHardGuardConfigPath();
const paths = computeExtensionPaths(agentDir);
// The single process.platform read for the whole extension, resolved once
// into the path-language flavor that every consumer shares (the session's
@@ -101,6 +107,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
const permissionManager = new PermissionManager({
agentDir,
globalConfigPath,
flavor: hostFlavor,
isYoloEnabled,
});
@@ -115,6 +122,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
agentDir,
policyPaths: permissionManager,
logger,
configPath: globalConfigPath,
});
const prompter = new PermissionPrompter({ logger });
@@ -164,8 +172,9 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
// access-path → path-values unwrap. Constructed here (before `session`) so
// the forwarded-request server's ServingPolicy can resolve against it; the
// service and gates below share this one instance.
const hardGuardLoader = new HardGuardPolicyLoader(hardGuardConfigPath);
const hardGuardManager = new PermissionManager({
policyLoader: new HardGuardPolicyLoader(getHardGuardConfigPath(agentDir)),
policyLoader: hardGuardLoader,
flavor: hostFlavor,
});
const resolver = new PermissionResolver(
@@ -224,7 +233,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
// scope (fail closed); session_start reloads with the real trust decision.
configStore.refresh(undefined, false);
const configPath = getGlobalConfigPath(agentDir);
const configPath = globalConfigPath;
registerPermissionSystemCommand(pi, {
config: configStore,
configPath,
@@ -295,6 +304,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
session,
formatterRegistry,
accessExtractorRegistry,
() => hardGuardLoader.getProtectedWrites(),
);
const skillInputGatePipeline = new SkillInputGatePipeline(resolver);
const gates = new PermissionGateHandler(
+10 -3
View File
@@ -108,6 +108,7 @@ export interface PermissionManagerOptions extends PolicyLoaderOptions {
export class PermissionManager implements ScopedPermissionManager {
private readonly agentDir: string | undefined;
private readonly globalConfigPath: string;
private readonly flavor: PathFlavor;
private readonly isYoloEnabled: () => boolean;
private loader: PolicyLoader;
@@ -118,13 +119,18 @@ export class PermissionManager implements ScopedPermissionManager {
constructor(options: PermissionManagerOptions = {}) {
this.agentDir = options.agentDir;
this.globalConfigPath = options.globalConfigPath ?? getGlobalConfigPath();
this.flavor = options.flavor ?? posixPathFlavor;
this.isYoloEnabled = options.isYoloEnabled ?? YOLO_DISABLED;
this.loader =
options.policyLoader ??
new FilePolicyLoader(
options.agentDir !== undefined
? derivePolicyLoaderOptions(options.agentDir, undefined)
? derivePolicyLoaderOptions(
options.agentDir,
undefined,
this.globalConfigPath,
)
: options,
);
}
@@ -139,7 +145,7 @@ export class PermissionManager implements ScopedPermissionManager {
configureForCwd(cwd: string | undefined | null): void {
if (this.agentDir !== undefined) {
this.loader = new FilePolicyLoader(
derivePolicyLoaderOptions(this.agentDir, cwd),
derivePolicyLoaderOptions(this.agentDir, cwd, this.globalConfigPath),
);
}
this.resolvedPermissionsCache.clear();
@@ -372,9 +378,10 @@ function buildCheckResult(
function derivePolicyLoaderOptions(
agentDir: string,
cwd: string | undefined | null,
globalConfigPath: string = getGlobalConfigPath(),
): PolicyLoaderOptions {
return {
globalConfigPath: getGlobalConfigPath(agentDir),
globalConfigPath,
agentsDir: join(agentDir, "agents"),
projectAgentsDir: cwd ? getProjectAgentsDir(cwd) : undefined,
};
+1 -1
View File
@@ -101,7 +101,7 @@ export interface PolicyLoader {
// ---------------------------------------------------------------------------
function defaultGlobalConfigPath(): string {
return getGlobalConfigPath(getAgentDir());
return getGlobalConfigPath();
}
function defaultAgentsDir(): string {
return join(getAgentDir(), "agents");
@@ -467,4 +467,75 @@ describe("AuthorizerSelection", () => {
);
});
});
describe("human-only asks (protected writes)", () => {
const humanOnlyDetails = (): PromptPermissionDetails =>
makeDetails({ humanOnly: true });
it("goes straight to the local user and never runs configured links", async () => {
const link = vi
.fn<Authorizer["authorize"]>()
.mockResolvedValue({ kind: "allow" });
const registry = new AuthorizerRegistry();
registry.register("judge", link);
const prompter = makePrompterApi();
const selection = new AuthorizerSelection(
makeDeps({
prompter,
authorizerRegistry: registry,
getAuthorizerChain: () => ["judge"],
}),
);
selection.activate(makeCtx({ hasUI: true }));
await selection.escalate(humanOnlyDetails());
expect(link).not.toHaveBeenCalled();
expect(prompter.prompt).toHaveBeenCalledWith(
expect.any(LocalUserAuthorizer),
expect.anything(),
);
});
it("denies without prompting when the terminal is DenyingAuthorizer", async () => {
const prompter = makeInvokingPrompter();
const selection = new AuthorizerSelection(makeDeps({ prompter }));
// No UI, not a subagent → DenyingAuthorizer terminal.
selection.activate(makeCtx({ hasUI: false }));
const decision = await selection.escalate(humanOnlyDetails());
expect(decision).toEqual({
approved: false,
state: "denied_with_reason",
denialReason:
"写入受保护文件需要人工确认,但当前没有可用的确认界面",
confirmationUnavailable: true,
decidedBy: {
kind: "unavailable",
reason: "protected_write_no_local_ui",
},
});
expect(prompter.prompt).toHaveBeenCalledOnce();
});
it("denies instead of relaying to a parent on a subagent node", async () => {
const prompter = makeInvokingPrompter();
const selection = new AuthorizerSelection(
makeDeps({ detection: makeDetection(true), prompter }),
);
selection.activate(makeCtx({ hasUI: false }));
const decision = await selection.escalate(humanOnlyDetails());
// ParentAuthorizer.forward would have been invoked if relaying were
// still allowed; instead the human-only denial is returned.
expect(decision.approved).toBe(false);
expect(decision.state).toBe("denied_with_reason");
expect(decision.denialReason).toBe(
"写入受保护文件需要人工确认,但当前没有可用的确认界面",
);
expect(prompter.prompt).toHaveBeenCalledOnce();
});
});
});
@@ -149,6 +149,33 @@ describe("LocalUserAuthorizer", () => {
);
});
it("hides the session option and forwards the system note on a human-only ask", async () => {
const { deps, decisionFn } = makeDeps();
const authorizer = new LocalUserAuthorizer(deps);
const deferNotes = [
{
origin: "system" as const,
text: "⚠ 该操作将写入受保护文件:/vault/auth.json(凭据,匹配 /vault/auth.json)。",
},
];
await authorizer.authorize(
makeDetails({
humanOnly: true,
deferNotes,
// A stale label must not resurrect the session option.
sessionLabel: "Yes, for 'read' tool",
}),
);
expect(decisionFn).toHaveBeenCalledWith(
expect.anything(),
expect.any(String),
expect.anything(),
{ hideSessionOption: true, deferNotes },
);
});
it("emits the UI event before calling requestPermissionDecision", async () => {
const calls: string[] = [];
const events = {
@@ -178,6 +178,37 @@ describe("requestPermissionDecisionFromUi", () => {
expect(options[1]).toBe("Yes, for this session");
});
describe("hideSessionOption (human-only asks)", () => {
it("omits the session option when hideSessionOption is set", async () => {
const selectFn = vi.fn().mockResolvedValue("Yes");
const ui: PermissionDecisionUi = { select: selectFn, input: vi.fn() };
await requestPermissionDecisionFromUi(ui, "Title", "Message", {
hideSessionOption: true,
});
const options = selectFn.mock.calls[0][1] as string[];
expect(options).toEqual(["Yes", "No", "No, provide reason"]);
});
it("does not open the scope select when the session option is hidden", async () => {
const selectFn = vi.fn().mockResolvedValue("Yes");
const ui: PermissionDecisionUi = { select: selectFn, input: vi.fn() };
const result = await requestPermissionDecisionFromUi(
ui,
"Title",
"Message",
{
hideSessionOption: true,
sessionScope: {
subagentLabel: "This subagent only",
servingSessionLabel: "The whole session",
},
},
);
expect(selectFn).toHaveBeenCalledTimes(1);
expect(result).toEqual({ approved: true, state: "approved" });
});
});
describe("sessionScope two-step (forwarded asks)", () => {
const sessionScope = {
subagentLabel: "This subagent only",
@@ -28,7 +28,6 @@ import {
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SUBAGENT_CHILD_SESSION_CREATED } from "#src/authority/subagent-lifecycle-events";
import { getSubagentSessionRegistry } from "#src/authority/subagent-registry";
import { getGlobalConfigPath } from "#src/config-paths";
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
import piPermissionSystemExtension from "#src/index";
import { PERMISSIONS_READY_CHANNEL } from "#src/permission-events";
@@ -55,9 +54,30 @@ const EXPECTED_HANDLERS = [
let agentDir: string;
/**
* Policy and hard-guard files live under the per-test temp dir and are passed
* to the factory explicitly — the bundle `config/` files are never touched.
*/
function testPolicyPath(): string {
return join(agentDir, "pi-permission-policy.jsonc");
}
function testHardGuardsPath(): string {
return join(agentDir, "pi-permission-hard-guards.jsonc");
}
function testConfigPaths(): { configPath: string; hardGuardConfigPath: string } {
return {
configPath: testPolicyPath(),
hardGuardConfigPath: testHardGuardsPath(),
};
}
beforeEach(() => {
agentDir = mkdtempSync(join(tmpdir(), "pi-perm-comp-root-"));
vi.stubEnv("PI_CODING_AGENT_DIR", agentDir);
writeFileSync(
testHardGuardsPath(),
JSON.stringify({ version: 1, permission: { "*": "allow" } }),
);
});
afterEach(() => {
@@ -75,9 +95,9 @@ afterEach(() => {
// ── Shared helpers ──────────────────────────────────────────────────────────
/** Write the global config file under the stubbed agent dir. */
/** Write the global policy file under the stubbed temp dir. */
function writeGlobalConfig(config: Record<string, unknown>): void {
const globalConfigPath = getGlobalConfigPath(agentDir);
const globalConfigPath = testPolicyPath();
mkdirSync(dirname(globalConfigPath), { recursive: true });
writeFileSync(
globalConfigPath,
@@ -174,7 +194,7 @@ function fireSessionStart(
describe("event-handler registration completeness", () => {
it("registers a handler for every required event exactly once", () => {
const pi = makeFakePi();
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
expect([...pi.handlers.keys()].sort()).toEqual(EXPECTED_HANDLERS);
});
@@ -186,7 +206,7 @@ describe("shutdown teardown chain", () => {
it("unpublishes the service and unsubscribes the lifecycle on shutdown", async () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-teardown-cwd-"));
const pi = makeFakePi();
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
// The service is published at session_start, not at factory init.
await fireSessionStart(pi, makeChildCtx(cwd, "top-session"));
@@ -219,7 +239,7 @@ describe("service and gate share one formatter registry", () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-ui-cwd-"));
const pi = makeFakePi({ toolNames: ["demo"] });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
const capturedTitles: string[] = [];
const { ctx } = makeUiCtx(cwd, capturedTitles);
@@ -257,7 +277,7 @@ describe("service and gate share one access extractor registry", () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-ext-cwd-"));
const pi = makeFakePi({ toolNames: ["ffgrep"] });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
const { ctx } = makeUiCtx(cwd, []);
await fireSessionStart(pi, ctx);
@@ -295,7 +315,7 @@ describe("service and chain share one authorizer registry", () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-auth-cwd-"));
const pi = makeFakePi({ toolNames: ["demo"] });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
const capturedTitles: string[] = [];
const { ctx } = makeUiCtx(cwd, capturedTitles);
@@ -327,7 +347,7 @@ describe("service and chain share one authorizer registry", () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-auth-optin-"));
const pi = makeFakePi({ toolNames: ["demo"] });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
const capturedTitles: string[] = [];
const { ctx } = makeUiCtx(cwd, capturedTitles);
@@ -364,7 +384,7 @@ describe("ready emitted after service publication", () => {
seen.push(getPermissionsService() ? "present" : "missing");
});
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
// ready is not emitted at load; only after session_start publishes.
expect(seen).toEqual([]);
@@ -389,7 +409,7 @@ describe("single source of truth for session state", () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-sot-cwd-"));
const pi = makeFakePi({ toolNames: ["demo"] });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
// UI ctx that approves the gate prompt for this session (options[1]).
// Return the second option label-agnostically — always the "for this
@@ -436,7 +456,7 @@ describe("service path queries evaluate the supplied path (#503)", () => {
writeGlobalConfig({ permission: { path: { [target]: "deny" } } });
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
await fireSessionStart(pi, makeChildCtx(cwd, "svc-path-session"));
const result = getPermissionsService()!.checkPermission("path", target);
@@ -453,7 +473,7 @@ describe("project trust gates project-scoped config (#644)", () => {
writeProjectConfig(cwd, { permission: { bash: "allow" } });
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
await fireSessionStart(
pi,
makeBaseCtx(cwd, "untrusted-session", { isProjectTrusted: false }),
@@ -473,7 +493,7 @@ describe("project trust gates project-scoped config (#644)", () => {
writeProjectConfig(cwd, { permission: { bash: "allow" } });
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
await fireSessionStart(
pi,
makeBaseCtx(cwd, "trusted-session", { isProjectTrusted: true }),
@@ -516,7 +536,7 @@ describe("bash bare-token path gating (#509, #645)", () => {
});
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
const ctx = makeChildCtx(cwd, "bare-token-session-deny");
await fireSessionStart(pi, ctx);
@@ -534,7 +554,7 @@ describe("bash bare-token path gating (#509, #645)", () => {
});
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
const ctx = makeChildCtx(cwd, "bare-token-session-wildcard");
await fireSessionStart(pi, ctx);
@@ -556,7 +576,7 @@ describe("bash bare-token path gating (#509, #645)", () => {
});
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
const ctx = makeChildCtx(cwd, "bare-symlink-session-deny");
await fireSessionStart(pi, ctx);
@@ -573,7 +593,7 @@ describe("bash bare-token path gating (#509, #645)", () => {
});
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
const ctx = makeChildCtx(cwd, "bare-token-session-unaffected");
await fireSessionStart(pi, ctx);
@@ -592,7 +612,7 @@ describe("bash bare-token path gating (#509, #645)", () => {
});
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
const ctx = makeChildCtx(cwd, "bare-token-session-absent");
await fireSessionStart(pi, ctx);
@@ -613,7 +633,7 @@ describe("bash bare-token path gating (#509, #645)", () => {
});
const pi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
const ctx = makeChildCtx(cwd, "bare-token-session-norule");
await fireSessionStart(pi, ctx);
@@ -637,9 +657,9 @@ describe("multi-instance global service interplay", () => {
const childSessionId = "child-session-mi";
const parentPi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(parentPi as unknown as ExtensionAPI);
piPermissionSystemExtension(parentPi as unknown as ExtensionAPI, testConfigPaths());
const childPi = makeFakePi({ events: createEventBus() });
piPermissionSystemExtension(childPi as unknown as ExtensionAPI);
piPermissionSystemExtension(childPi as unknown as ExtensionAPI, testConfigPaths());
// The parent is not a registered child, so it publishes its service.
await fireSessionStart(
@@ -703,7 +723,7 @@ describe("session approvals do not leak across same-cwd session switches", () =>
// ── Session #1: approve `demo` for the session, then shut down ──────────
const firstPi = makeFakePi({ toolNames: ["demo"] });
piPermissionSystemExtension(firstPi as unknown as ExtensionAPI);
piPermissionSystemExtension(firstPi as unknown as ExtensionAPI, testConfigPaths());
const firstCtx = makeSessionApprovingCtx(cwd, "switch-session-1");
await fireSessionStart(firstPi, firstCtx);
@@ -724,7 +744,7 @@ describe("session approvals do not leak across same-cwd session switches", () =>
// ── Session #2: the re-invoked cached factory, same cwd ────────────────
const secondPi = makeFakePi({ toolNames: ["demo"] });
piPermissionSystemExtension(secondPi as unknown as ExtensionAPI);
piPermissionSystemExtension(secondPi as unknown as ExtensionAPI, testConfigPaths());
await fireSessionStart(secondPi, makeChildCtx(cwd, "switch-session-2"));
@@ -776,12 +796,12 @@ describe("forwarded grant-scope selection round-trip", () => {
const parentBus = createEventBus();
const parentPi = makeFakePi({ events: parentBus, toolNames: ["demo"] });
piPermissionSystemExtension(parentPi as unknown as ExtensionAPI);
piPermissionSystemExtension(parentPi as unknown as ExtensionAPI, testConfigPaths());
const childPi = makeFakePi({
events: createEventBus(),
toolNames: ["demo"],
});
piPermissionSystemExtension(childPi as unknown as ExtensionAPI);
piPermissionSystemExtension(childPi as unknown as ExtensionAPI, testConfigPaths());
// The parent starts serving (its poll drains the inbox using the UI ctx).
const parentCtx = makeServingCtx(
@@ -841,12 +861,12 @@ describe("forwarded grant-scope selection round-trip", () => {
const parentBus = createEventBus();
const parentPi = makeFakePi({ events: parentBus, toolNames: ["demo"] });
piPermissionSystemExtension(parentPi as unknown as ExtensionAPI);
piPermissionSystemExtension(parentPi as unknown as ExtensionAPI, testConfigPaths());
const childPi = makeFakePi({
events: createEventBus(),
toolNames: ["demo"],
});
piPermissionSystemExtension(childPi as unknown as ExtensionAPI);
piPermissionSystemExtension(childPi as unknown as ExtensionAPI, testConfigPaths());
const parentCtx = makeServingCtx(
parentCwd,
@@ -905,7 +925,7 @@ describe("yolo grants asks synthesized after resolution", () => {
writeGlobalConfig(config);
const cwd = mkdtempSync(join(tmpdir(), "pi-perm-yolo-cwd-"));
const pi = makeFakePi({ toolNames: ["bash"] });
piPermissionSystemExtension(pi as unknown as ExtensionAPI);
piPermissionSystemExtension(pi as unknown as ExtensionAPI, testConfigPaths());
const prompts: string[] = [];
const { ctx } = makeUiCtx(cwd, prompts);
+22 -68
View File
@@ -1,6 +1,6 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
@@ -673,13 +673,14 @@ describe("loadAndMergeConfigs", () => {
let tempDir: string;
let agentDir: string;
let cwd: string;
let extensionRoot: string;
let globalConfigPath: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "config-merge-test-"));
agentDir = join(tempDir, "agent");
cwd = join(tempDir, "project");
extensionRoot = join(tempDir, "ext");
// Tests inject a temp policy path; the bundle config/ file is never touched.
globalConfigPath = join(tempDir, "config", "pi-permission-policy.jsonc");
});
afterEach(() => {
@@ -687,9 +688,8 @@ describe("loadAndMergeConfigs", () => {
});
function writeGlobal(content: Record<string, unknown>): void {
const dir = join(agentDir, "my-pi", "permission-system");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "policy.jsonc"), JSON.stringify(content));
mkdirSync(dirname(globalConfigPath), { recursive: true });
writeFileSync(globalConfigPath, JSON.stringify(content));
}
function writeProject(content: Record<string, unknown>): void {
@@ -712,72 +712,26 @@ describe("loadAndMergeConfigs", () => {
writeFileSync(join(dir, "pi-permissions.jsonc"), JSON.stringify(content));
}
function writeLegacyExtensionConfig(content: Record<string, unknown>): void {
mkdirSync(extensionRoot, { recursive: true });
writeFileSync(join(extensionRoot, "config.json"), JSON.stringify(content));
}
it("detects legacy global policy and emits migration issue", () => {
it("ignores leftover files at legacy locations", () => {
writeLegacyGlobalPolicy({
defaultPolicy: { tools: "allow" },
tools: { read: "allow" },
});
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
expect(result.issues).toHaveLength(1);
expect(result.issues[0]).toContain("pi-permissions.jsonc");
expect(result.issues[0]).toContain("my-pi/permission-system");
// Legacy file has no flat-format permission key — no rules extracted
expect(result.merged.permission).toBeUndefined();
});
it("detects legacy extension runtime config and emits migration issue", () => {
writeLegacyExtensionConfig({
debugLog: true,
yoloMode: true,
});
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
expect(result.issues).toHaveLength(1);
expect(result.issues[0]).toContain(extensionRoot);
expect(result.merged.debugLog).toBe(true);
expect(result.merged.yoloMode).toBe(true);
});
it("does not emit legacy extension config issue when its path equals the runtime legacy path", () => {
const newGlobalDir = join(agentDir, "extensions", "pi-permission-system");
mkdirSync(newGlobalDir, { recursive: true });
writeFileSync(
join(newGlobalDir, "config.json"),
JSON.stringify({ debugLog: true }),
);
const result = loadAndMergeConfigs(agentDir, cwd, newGlobalDir);
expect(result.issues.filter((i) => i.includes("legacy"))).toHaveLength(0);
});
it("emits no issues when no legacy files exist and no new files exist", () => {
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
expect(result.issues).toEqual([]);
});
it("new-layout config takes precedence over legacy config at same scope", () => {
writeGlobal({
permission: { "*": "deny" },
});
writeLegacyGlobalPolicy({
permission: { "*": "allow" },
});
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
// New layout wins (legacy loaded first, new layout loaded second → new wins)
const result = loadAndMergeConfigs(globalConfigPath);
// The legacy file is simply ignored — never merged, never flagged.
expect(result.issues).toEqual([]);
expect(result.merged.permission).toEqual({ "*": "deny" });
// But legacy still emits a migration warning
expect(result.issues.some((i) => i.includes("pi-permissions.jsonc"))).toBe(
true,
);
});
it("emits no issues when no config file exists", () => {
const result = loadAndMergeConfigs(globalConfigPath);
expect(result.issues).toEqual([]);
expect(result.merged).toEqual({});
});
it("warns when the merged config leaves bash inheriting a permissive top-level '*'", () => {
@@ -785,7 +739,7 @@ describe("loadAndMergeConfigs", () => {
permission: { "*": "allow", read: "allow" },
});
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
const result = loadAndMergeConfigs(globalConfigPath);
expect(result.issues.some((i) => i.includes("bash"))).toBe(true);
});
@@ -794,7 +748,7 @@ describe("loadAndMergeConfigs", () => {
permission: { "*": "allow", bash: { "*": "ask" } },
});
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
const result = loadAndMergeConfigs(globalConfigPath);
expect(result.issues).toEqual([]);
});
@@ -803,7 +757,7 @@ describe("loadAndMergeConfigs", () => {
writeProject({ permission: { bash: "allow" } });
writeLegacyProjectPolicy({ permission: { "*": "allow" } });
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
const result = loadAndMergeConfigs(globalConfigPath);
expect(result.merged.permission).toEqual({ "*": "ask", bash: "deny" });
expect(result.issues).toEqual([]);
@@ -816,7 +770,7 @@ describe("loadAndMergeConfigs", () => {
toolInputPreviewMaxLength: 400,
});
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
const result = loadAndMergeConfigs(globalConfigPath);
expect(result.issues).toHaveLength(1);
expect(result.issues[0]).toContain("toolInputPreviewMaxLength");
@@ -828,7 +782,7 @@ describe("loadAndMergeConfigs", () => {
toolTextSummaryMaxLength: 120,
});
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
const result = loadAndMergeConfigs(globalConfigPath);
expect(result.merged.permission).toEqual({ "*": "ask", bash: "deny" });
});
@@ -836,7 +790,7 @@ describe("loadAndMergeConfigs", () => {
it("stays silent when neither cap is set", () => {
writeGlobal({ permission: { "*": "ask", bash: "deny" } });
const result = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
const result = loadAndMergeConfigs(globalConfigPath);
expect(result.issues).toEqual([]);
});
+16 -11
View File
@@ -1,30 +1,35 @@
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
getGlobalConfigPath,
getGlobalLogsDir,
getLegacyGlobalPolicyPath,
getHardGuardConfigPath,
} from "#src/config-paths";
// Bundle-level `config/` is the single source of truth — two levels up from
// both this test file and src/config-paths.ts.
const BUNDLE_CONFIG_DIR = fileURLToPath(new URL("../../config", import.meta.url));
describe("config-paths", () => {
const agentDir = "/home/user/.pi/agent";
it("places the global policy at my-pi/permission-system/policy.jsonc under the agent dir", () => {
expect(getGlobalConfigPath(agentDir)).toBe(
join(agentDir, "my-pi", "permission-system", "policy.jsonc"),
it("reads the global policy from the bundle-level config directory", () => {
expect(getGlobalConfigPath()).toBe(
join(BUNDLE_CONFIG_DIR, "pi-permission-policy.jsonc"),
);
});
it("places logs next to the global config dir", () => {
it("reads hard guards from the bundle-level config directory", () => {
expect(getHardGuardConfigPath()).toBe(
join(BUNDLE_CONFIG_DIR, "pi-permission-hard-guards.jsonc"),
);
});
it("keeps logs under the disposable agent-dir my-pi tree", () => {
expect(getGlobalLogsDir(agentDir)).toBe(
join(agentDir, "my-pi", "permission-system", "logs"),
);
});
it("keeps the legacy global policy path for migration detection", () => {
expect(getLegacyGlobalPolicyPath(agentDir)).toBe(
join(agentDir, "pi-permissions.jsonc"),
);
});
});
@@ -1,6 +1,6 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { loadAndMergeConfigs } from "#src/config-loader";
@@ -20,15 +20,12 @@ import {
*/
describe("config pipeline seam", () => {
let tempDir: string;
let agentDir: string;
let cwd: string;
let extensionRoot: string;
let globalConfigPath: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "config-pipeline-test-"));
agentDir = join(tempDir, "agent");
cwd = join(tempDir, "project");
extensionRoot = join(tempDir, "ext");
// Tests inject a temp policy path; the bundle config/ file is never touched.
globalConfigPath = join(tempDir, "config", "pi-permission-policy.jsonc");
});
afterEach(() => {
@@ -36,15 +33,14 @@ describe("config pipeline seam", () => {
});
function writeGlobal(content: Record<string, unknown>): void {
const dir = join(agentDir, "my-pi", "permission-system");
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "policy.jsonc"), JSON.stringify(content));
mkdirSync(dirname(globalConfigPath), { recursive: true });
writeFileSync(globalConfigPath, JSON.stringify(content));
}
it("a runtime knob survives the full pipeline", () => {
writeGlobal({ debugLog: true });
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
const mergeResult = loadAndMergeConfigs(globalConfigPath);
const config = normalizePermissionSystemConfig(mergeResult.merged);
expect(config.debugLog).toBe(true);
@@ -56,7 +52,7 @@ describe("config pipeline seam", () => {
it("a deprecated preview cap reaches the merge intermediate but not the runtime config", () => {
writeGlobal({ toolInputPreviewMaxLength: 1000 });
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
const mergeResult = loadAndMergeConfigs(globalConfigPath);
const config = normalizePermissionSystemConfig(mergeResult.merged);
expect(mergeResult.merged.toolInputPreviewMaxLength).toBe(1000);
@@ -66,7 +62,7 @@ describe("config pipeline seam", () => {
it("dialog budget fields survive the full pipeline and resolve to a render budget", () => {
writeGlobal({ promptMaxRows: 12, promptFieldMaxWidth: 80 });
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
const mergeResult = loadAndMergeConfigs(globalConfigPath);
const config = normalizePermissionSystemConfig(mergeResult.merged);
expect(config.promptMaxRows).toBe(12);
@@ -78,7 +74,7 @@ describe("config pipeline seam", () => {
});
it("falls back to the default render budget when the config names neither field", () => {
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
const mergeResult = loadAndMergeConfigs(globalConfigPath);
const config = normalizePermissionSystemConfig(mergeResult.merged);
expect(resolveRenderBudget(config)).toEqual(DEFAULT_RENDER_BUDGET);
@@ -87,7 +83,7 @@ describe("config pipeline seam", () => {
it("a deprecated text-summary cap likewise stops at the merge intermediate", () => {
writeGlobal({ toolTextSummaryMaxLength: 250 });
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
const mergeResult = loadAndMergeConfigs(globalConfigPath);
const config = normalizePermissionSystemConfig(mergeResult.merged);
expect(mergeResult.merged.toolTextSummaryMaxLength).toBe(250);
@@ -96,8 +92,8 @@ describe("config pipeline seam", () => {
it("defaults apply when config file is absent", () => {
// No config files written — agentDir and cwd directories don't exist.
const mergeResult = loadAndMergeConfigs(agentDir, cwd, extensionRoot);
// No config file written — the injected path does not exist.
const mergeResult = loadAndMergeConfigs(globalConfigPath);
const config = normalizePermissionSystemConfig(mergeResult.merged);
expect(config.debugLog).toBe(false);
@@ -15,7 +15,7 @@ import { PermissionManager } from "#src/permission-manager";
test("buildResolvedConfigLogEntry reports global and agent policy paths", () => {
const policyPaths: ResolvedPolicyPaths = {
globalConfigPath: "/home/user/.pi/agent/my-pi/permission-system/policy.jsonc",
globalConfigPath: "/bundle/config/pi-permission-policy.jsonc",
globalConfigExists: true,
agentsDir: "/home/user/.pi/agent/agents",
agentsDirExists: true,
@@ -25,11 +25,7 @@ test("buildResolvedConfigLogEntry reports global and agent policy paths", () =>
const result = buildResolvedConfigLogEntry({ policyPaths });
expect(result).toEqual({
...policyPaths,
legacyGlobalPolicyDetected: false,
legacyExtensionConfigDetected: false,
});
expect(result).toEqual(policyPaths);
});
test("config.resolved entry appears in review log via logger", () => {
@@ -78,8 +74,6 @@ test("config.resolved entry appears in review log via logger", () => {
expect(parsed.agentsDirExists).toBe(false);
expect(parsed.projectAgentsDir).toBe(null);
expect(parsed.projectAgentsDirExists).toBe(false);
expect(parsed.legacyGlobalPolicyDetected).toBe(false);
expect(parsed.legacyExtensionConfigDetected).toBe(false);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
+13 -36
View File
@@ -122,6 +122,7 @@ function makeStore(overrides: Partial<ConfigStoreDeps> = {}): {
agentDir: "/test/agent",
policyPaths: makePolicyPathProvider(),
logger,
configPath: "/test/bundle-config/pi-permission-policy.jsonc",
...overrides,
};
return { store: new ConfigStore(deps), logger };
@@ -159,34 +160,26 @@ describe("ConfigStore", () => {
// ── refresh() ─────────────────────────────────────────────────────────
describe("refresh()", () => {
it("uses the passed ctx cwd for global-only config loading", () => {
it("loads the configured global policy path", () => {
const { store } = makeStore();
store.refresh(makeCtx({ cwd: "/my/project" }), true);
expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(
"/test/agent",
"/my/project",
expect.any(String),
"/test/bundle-config/pi-permission-policy.jsonc",
);
});
it("does not add a project config scope when the project is untrusted", () => {
it("loads the same global policy regardless of project trust", () => {
const { store } = makeStore();
store.refresh(makeCtx({ cwd: "/my/project" }), false);
expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(
"/test/agent",
"/my/project",
expect.any(String),
"/test/bundle-config/pi-permission-policy.jsonc",
);
});
it("uses empty string cwd when no ctx is provided", () => {
const { store } = makeStore();
it("falls back to the bundle policy path when no override is configured", () => {
const { store } = makeStore({ configPath: undefined });
store.refresh(undefined, true);
expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(
"/test/agent",
"",
expect.any(String),
);
expect(mockLoadAndMergeConfigs).toHaveBeenCalledWith(undefined);
});
it("updates current() with normalized merged result", () => {
@@ -433,30 +426,14 @@ describe("ConfigStore", () => {
expect(mockProvider.getResolvedPolicyPaths).toHaveBeenCalled();
});
it("passes legacy detection results to buildResolvedConfigLogEntry", () => {
it("passes the resolved policy paths to buildResolvedConfigLogEntry", () => {
const { store } = makeStore();
// Make one legacy path exist
mockExistsSync.mockImplementation((p: string) =>
p.includes("policies.json"),
);
store.logResolvedPaths("/some/project");
expect(mockBuildResolvedConfigLogEntry).toHaveBeenCalledWith(
expect.objectContaining({
legacyGlobalPolicyDetected: expect.any(Boolean),
legacyExtensionConfigDetected: expect.any(Boolean),
expect(mockBuildResolvedConfigLogEntry).toHaveBeenCalledWith({
policyPaths: expect.objectContaining({
globalConfigPath: "/agent/config.json",
}),
);
});
it("checks only supported global legacy paths", () => {
const { store } = makeStore();
store.logResolvedPaths(); // no cwd
// existsSync called for global and ext-config legacy paths only (not project)
const calls = mockExistsSync.mock.calls.map(([p]: [string]) => p);
const projectCalls = calls.filter(
(p) => p.includes("/null/") || p.includes("null"),
);
expect(projectCalls).toHaveLength(0);
});
});
});
});
@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from "vitest";
import { protectedWriteDeferNote } from "#src/handlers/gates/protected-write";
import type { ProtectedWriteTarget } from "#src/handlers/gates/descriptor";
import type { PermissionCheckResult } from "#src/types";
import { DECIDED_BY_HUMAN } from "#test/helpers/decision-fixtures";
import { makeDescriptor, makeGateRunner } from "#test/helpers/gate-fixtures";
import { makeCheckResult } from "#test/helpers/handler-fixtures";
const MARKER: ProtectedWriteTarget = {
path: "/vault/auth.json",
pattern: "/vault/auth.json",
label: "Pi 凭据",
extraCount: 0,
};
function protectedDescriptor(check: PermissionCheckResult) {
return makeDescriptor({
humanOnly: MARKER,
sessionApproval: undefined,
preCheck: check,
});
}
describe("GateRunner — humanOnly protected writes", () => {
it("still escalates when the resolved check is a session grant", async () => {
const { runner, deps } = makeGateRunner();
const outcome = await runner.run(
protectedDescriptor(
makeCheckResult({
state: "ask",
source: "session",
matchedPattern: "/vault/**",
}),
),
null,
);
expect(deps.escalate).toHaveBeenCalledOnce();
expect(outcome).toEqual({ action: "allow" });
expect(deps.reporter.writeReviewLog).not.toHaveBeenCalledWith(
"permission_request.session_approved",
expect.anything(),
);
});
it("still escalates when yolo mode is enabled", async () => {
const { runner, deps } = makeGateRunner({ yolo: true });
await runner.run(
protectedDescriptor(
makeCheckResult({ state: "ask", source: "tool", origin: "builtin" }),
),
null,
);
expect(deps.escalate).toHaveBeenCalledOnce();
expect(deps.reporter.writeReviewLog).not.toHaveBeenCalledWith(
"permission_request.auto_approved",
expect.anything(),
);
});
it("escalates with humanOnly, the system note, and no session approval", async () => {
const { runner, deps } = makeGateRunner();
await runner.run(
protectedDescriptor(
makeCheckResult({ state: "ask", source: "tool", origin: "builtin" }),
),
null,
);
expect(deps.escalate).toHaveBeenCalledWith(
expect.objectContaining({
humanOnly: true,
deferNotes: [protectedWriteDeferNote(MARKER)],
}),
);
const details = vi.mocked(deps.escalate).mock.calls[0][0];
expect(details.sessionApproval).toBeUndefined();
});
it("records no session grant when the user approves", async () => {
const { runner, deps } = makeGateRunner({
escalate: vi.fn().mockResolvedValue({
approved: true,
state: "approved_for_session",
decidedBy: DECIDED_BY_HUMAN,
}),
});
const outcome = await runner.run(
protectedDescriptor(
makeCheckResult({ state: "ask", source: "tool", origin: "builtin" }),
),
null,
);
// A session-shaped approval cannot be recorded: the descriptor carries no
// sessionApproval to grant.
expect(deps.recordSessionApproval).not.toHaveBeenCalled();
expect(outcome).toEqual({ action: "allow" });
});
});
describe("protectedWriteDeferNote", () => {
it("renders the exact system note", () => {
expect(protectedWriteDeferNote(MARKER)).toEqual({
origin: "system",
text: "⚠ 该操作将写入受保护文件:/vault/auth.json(Pi 凭据,匹配 /vault/auth.json)。此类写入不经 AutoReview,必须由你人工确认。",
});
});
it("adds the extra-target count", () => {
const note = protectedWriteDeferNote({ ...MARKER, extraCount: 2 });
expect(note.text).toContain(",另有 2 个受保护目标");
});
});
@@ -0,0 +1,300 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
// Mock node:fs so realpathSync (used by canonicalizePath) is controllable for
// the symlink-into-protected-directory case; identity by default.
const realpathSync = vi.hoisted(() =>
vi.fn<(path: string) => string>((p) => p),
);
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
realpathSync,
default: { ...actual, realpathSync },
};
});
import { BashProgram } from "#src/access-intent/bash/program";
import type { ShellInvocation } from "#src/access-intent/tool-kind";
import {
type GateDescriptor,
type GateResult,
isGateDescriptor,
} from "#src/handlers/gates/descriptor";
import { describeProtectedWriteGate } from "#src/handlers/gates/protected-write";
import type { ProtectedWriteRule } from "#src/hard-guard-loader";
import { pathFlavorForPlatform } from "#src/path/path-flavor";
import { PathNormalizer } from "#src/path-normalizer";
import { makeTcc } from "#test/helpers/gate-fixtures";
const CWD = "/test/project";
beforeEach(() => {
realpathSync.mockReset();
realpathSync.mockImplementation((p: string) => p);
});
const PROTECTED_WRITES: ProtectedWriteRule[] = [
{ pattern: "/vault/auth.json", label: "Pi 凭据" },
{ pattern: "/protected/**", label: "受保护目录" },
];
function normalizer(): PathNormalizer {
return new PathNormalizer(pathFlavorForPlatform(process.platform), CWD);
}
async function describeBashCall(
command: string,
rules: readonly ProtectedWriteRule[] = PROTECTED_WRITES,
) {
const tcc = makeTcc({ toolName: "bash", input: { command } });
const bashProgram = command
? await BashProgram.parse(command, normalizer())
: null;
const shell: ShellInvocation = { command, workdir: undefined };
return describeProtectedWriteGate(
tcc,
bashProgram,
shell,
normalizer(),
rules,
);
}
function describeToolCall(
toolName: string,
input: unknown,
rules: readonly ProtectedWriteRule[] = PROTECTED_WRITES,
) {
const tcc = makeTcc({ toolName, input });
return describeProtectedWriteGate(tcc, null, null, normalizer(), rules);
}
function asDescriptor(result: GateResult): GateDescriptor {
expect(isGateDescriptor(result)).toBe(true);
return result as GateDescriptor;
}
describe("describeProtectedWriteGate", () => {
describe("write/edit tools", () => {
it("prompts on a write tool targeting a protected file", () => {
const desc = asDescriptor(
describeToolCall("write", { path: "/vault/auth.json" }),
);
expect(desc.humanOnly).toEqual({
path: "/vault/auth.json",
pattern: "/vault/auth.json",
label: "Pi 凭据",
extraCount: 0,
});
expect(desc.preCheck?.state).toBe("ask");
expect(desc.sessionApproval).toBeUndefined();
expect(desc.logContext.protectedWrite).toEqual({
path: "/vault/auth.json",
pattern: "/vault/auth.json",
label: "Pi 凭据",
});
});
it("prompts on an edit tool targeting a protected file", () => {
expect(
isGateDescriptor(
describeToolCall("edit", { path: "/vault/auth.json" }),
),
).toBe(true);
});
it("does not gate a read of a protected path", () => {
expect(
describeToolCall("read", { path: "/vault/auth.json" }),
).toBeNull();
});
it("covers a whole subtree, not just the directory root", () => {
expect(
isGateDescriptor(
describeToolCall("write", { path: "/protected/deep/nested/x.json" }),
),
).toBe(true);
});
it("prompts on a symlink whose target is protected", () => {
realpathSync.mockImplementation((p: string) =>
p === "/work/link" ? "/vault/auth.json" : p,
);
const desc = asDescriptor(
describeToolCall("write", { path: "/work/link" }),
);
expect(desc.humanOnly?.pattern).toBe("/vault/auth.json");
});
});
describe("bash writes", () => {
it("prompts on a redirect into a protected file", async () => {
expect(
isGateDescriptor(await describeBashCall("echo x > /vault/auth.json")),
).toBe(true);
});
it("prompts on a redirect into a not-yet-existing protected path", async () => {
expect(
isGateDescriptor(
await describeBashCall("echo 1 > /protected/new-file.json"),
),
).toBe(true);
});
it("does not prompt on cat of a protected file", async () => {
expect(await describeBashCall("cat /vault/auth.json")).toBeNull();
});
it("does not prompt when a protected read feeds an unprotected write", async () => {
expect(
await describeBashCall("cat /vault/auth.json > /tmp/x"),
).toBeNull();
});
it("does not prompt on a redirect-in read of a protected file", async () => {
expect(await describeBashCall("cat < /vault/auth.json")).toBeNull();
});
it("prompts on tee of a protected file", async () => {
expect(
isGateDescriptor(await describeBashCall("tee /vault/auth.json")),
).toBe(true);
});
it("prompts on cp into a protected file", async () => {
expect(
isGateDescriptor(
await describeBashCall("cp /tmp/x /vault/auth.json"),
),
).toBe(true);
});
it("prompts on sed -i of a protected file", async () => {
expect(
isGateDescriptor(
await describeBashCall("sed -i s/a/b/ /vault/auth.json"),
),
).toBe(true);
});
it("prompts on an opaque bash -c payload writing a protected file", async () => {
expect(
isGateDescriptor(
await describeBashCall('bash -c "echo > /vault/auth.json"'),
),
).toBe(true);
});
it("prompts on a relative redirect after cd into a protected directory", async () => {
expect(
isGateDescriptor(
await describeBashCall("cd /protected && echo 1 > rel.json"),
),
).toBe(true);
});
it("prompts on an unparseable command containing a protected path", async () => {
const tcc = makeTcc({
toolName: "bash",
input: { command: "echo broken > /vault/auth.json" },
});
// Parser failure is injected: the gate must scan the text directly.
const shell: ShellInvocation = {
command: "echo broken > /vault/auth.json",
workdir: undefined,
};
const result = describeProtectedWriteGate(
tcc,
null,
shell,
normalizer(),
PROTECTED_WRITES,
);
expect(isGateDescriptor(result)).toBe(true);
});
it("reports the extra protected targets in the same call", async () => {
const desc = asDescriptor(
await describeBashCall(
"echo x > /vault/auth.json && echo y > /protected/a.json",
),
);
expect(desc.humanOnly?.extraCount).toBe(1);
});
});
describe("non-local tools and empty rules", () => {
it("does not gate an ssh_bash-style aliased shell tool", async () => {
const tcc = makeTcc({
toolName: "ssh_bash",
input: { command: "echo x > /vault/auth.json" },
});
const bashProgram = await BashProgram.parse(
"echo x > /vault/auth.json",
normalizer(),
);
const shell: ShellInvocation = {
command: "echo x > /vault/auth.json",
workdir: undefined,
};
expect(
describeProtectedWriteGate(
tcc,
bashProgram,
shell,
normalizer(),
PROTECTED_WRITES,
),
).toBeNull();
});
it("returns null when no protectedWrites are configured", async () => {
expect(
await describeBashCall("echo x > /vault/auth.json", []),
).toBeNull();
expect(
describeToolCall("write", { path: "/vault/auth.json" }, []),
).toBeNull();
});
});
describe("bare token existence probe", () => {
// `auth.json` is a bare token (no path shape), so it only becomes a
// candidate when the resolver's existence probe finds it on disk; the
// probe must carry the same role/program metadata as shaped tokens.
it("does not prompt on a read of an existing bare-token file in a protected directory", async () => {
const dir = mkdtempSync(join(tmpdir(), "pw-probe-"));
try {
writeFileSync(join(dir, "auth.json"), "x");
const rules = [{ pattern: `${dir}/**`, label: "受保护目录" }];
expect(
await describeBashCall(`cd ${dir} && cat auth.json`, rules),
).toBeNull();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("prompts on rm of an existing bare-token file in a protected directory", async () => {
const dir = mkdtempSync(join(tmpdir(), "pw-probe-"));
try {
writeFileSync(join(dir, "auth.json"), "x");
const rules = [{ pattern: `${dir}/**`, label: "受保护目录" }];
expect(
isGateDescriptor(
await describeBashCall(`cd ${dir} && rm auth.json`, rules),
),
).toBe(true);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
});
@@ -1,6 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AccessPath } from "#src/access-intent/access-path";
import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
import { PathNormalizer } from "#src/path-normalizer";
@@ -37,7 +36,7 @@ function makeMockBashProgram(command = "echo hello") {
commandText: vi.fn(() => command),
commands: vi.fn<() => []>(() => []),
pathRuleCandidates: vi.fn<() => []>(() => []),
externalPaths: vi.fn<() => AccessPath[]>(() => []),
writeCandidates: vi.fn<() => []>(() => []),
};
}
@@ -200,7 +199,7 @@ describe("ToolCallGatePipeline", () => {
commandText: vi.fn(() => text),
commands: vi.fn(() => [{ text }]),
pathRuleCandidates: vi.fn<() => []>(() => []),
externalPaths: vi.fn<() => AccessPath[]>(() => []),
writeCandidates: vi.fn<() => []>(() => []),
};
}
@@ -1,5 +1,5 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { homedir, tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
@@ -7,10 +7,25 @@ import { HardGuardPolicyLoader } from "#src/hard-guard-loader";
const roots: string[] = [];
const ENV_KEYS = ["PI_CODING_AGENT_DIR", "XDG_CONFIG_HOME"] as const;
const savedEnv: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>> =
{};
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
for (const key of ENV_KEYS) {
if (savedEnv[key] === undefined) delete process.env[key];
else process.env[key] = savedEnv[key];
delete savedEnv[key];
}
});
function setEnv(key: (typeof ENV_KEYS)[number], value: string | undefined) {
if (!(key in savedEnv)) savedEnv[key] = process.env[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
describe("HardGuardPolicyLoader", () => {
it("loads only the global JSONC hard-guard file and preserves allow exceptions", () => {
const root = mkdtempSync(join(tmpdir(), "hard-guard-loader-"));
@@ -27,7 +42,7 @@ describe("HardGuardPolicyLoader", () => {
}
}`);
const loader = new HardGuardPolicyLoader(userPath, join(root, "missing-fallback.jsonc"));
const loader = new HardGuardPolicyLoader(userPath);
const config = loader.loadGlobalConfig();
expect(config.permission).toEqual({
@@ -38,13 +53,24 @@ describe("HardGuardPolicyLoader", () => {
expect(loader.getConfigIssues()).toEqual([]);
});
it("fails closed when neither the user file nor fallback is valid", () => {
it("fails closed with an issue when the file is missing", () => {
const root = mkdtempSync(join(tmpdir(), "hard-guard-loader-"));
roots.push(root);
const userPath = join(root, "hard-guards.jsonc");
const loader = new HardGuardPolicyLoader(userPath);
expect(loader.loadGlobalConfig().permission).toEqual({ "*": "deny" });
expect(loader.getConfigIssues().join("\n")).toContain("denying all");
});
it("fails closed when the file is invalid", () => {
const root = mkdtempSync(join(tmpdir(), "hard-guard-loader-"));
roots.push(root);
const userPath = join(root, "hard-guards.jsonc");
writeFileSync(userPath, `{ "version": 1, "permission": { "*": "ask" } }`);
const loader = new HardGuardPolicyLoader(userPath, join(root, "missing-fallback.jsonc"));
const loader = new HardGuardPolicyLoader(userPath);
expect(loader.loadGlobalConfig().permission).toEqual({ "*": "deny" });
expect(loader.getConfigIssues().join("\n")).toContain("allow or deny");
@@ -59,10 +85,7 @@ describe("HardGuardPolicyLoader", () => {
`{ "version": 2, "permission": { "*": "allow" } }`,
);
const loader = new HardGuardPolicyLoader(
userPath,
join(root, "missing-fallback.jsonc"),
);
const loader = new HardGuardPolicyLoader(userPath);
expect(loader.loadGlobalConfig().permission).toEqual({ "*": "deny" });
expect(loader.getConfigIssues().join("\n")).toContain(
@@ -79,10 +102,7 @@ describe("HardGuardPolicyLoader", () => {
`{ "version": 1, "permission": { "*": "allow", "~/.ssh/**": "deny" } }`,
);
const loader = new HardGuardPolicyLoader(
userPath,
join(root, "missing-fallback.jsonc"),
);
const loader = new HardGuardPolicyLoader(userPath);
const first = loader.loadGlobalConfig();
expect(first.permission).toEqual({
"*": "allow",
@@ -107,10 +127,7 @@ describe("HardGuardPolicyLoader", () => {
`{ "version": 1, "permission": { "*": "allow", "~/.ssh/**": "deny" } }`,
);
const loader = new HardGuardPolicyLoader(
userPath,
join(root, "missing-fallback.jsonc"),
);
const loader = new HardGuardPolicyLoader(userPath);
loader.loadGlobalConfig();
writeFileSync(userPath, "{ not json");
loader.loadGlobalConfig();
@@ -126,4 +143,106 @@ describe("HardGuardPolicyLoader", () => {
"~/.aws/**": "deny",
});
});
describe("protectedWrites", () => {
function writeConfig(userPath: string, protectedWrites: unknown): void {
writeFileSync(
userPath,
JSON.stringify({
version: 1,
permission: { "*": "allow" },
protectedWrites,
}),
);
}
function makeLoader() {
const root = mkdtempSync(join(tmpdir(), "hard-guard-loader-"));
roots.push(root);
const userPath = join(root, "hard-guards.jsonc");
return { root, userPath, loader: new HardGuardPolicyLoader(userPath) };
}
it("expands {agentDir} honoring PI_CODING_AGENT_DIR", () => {
setEnv("PI_CODING_AGENT_DIR", "/tmp/fake-agent-dir");
const { userPath, loader } = makeLoader();
writeConfig(userPath, [
{ pattern: "{agentDir}/auth.json", label: "Pi 凭据" },
]);
expect(loader.getProtectedWrites()).toEqual([
{ pattern: "/tmp/fake-agent-dir/auth.json", label: "Pi 凭据" },
]);
});
it("expands {configHome} honoring XDG_CONFIG_HOME", () => {
setEnv("XDG_CONFIG_HOME", "/tmp/fake-config-home");
const { userPath, loader } = makeLoader();
writeConfig(userPath, [
{ pattern: "{configHome}/my-pi/**", label: "pi-ssh 凭据库" },
]);
expect(loader.getProtectedWrites()).toEqual([
{ pattern: "/tmp/fake-config-home/my-pi/**", label: "pi-ssh 凭据库" },
]);
});
it("falls back to ~/.config for {configHome} and expands leading ~/", () => {
setEnv("XDG_CONFIG_HOME", undefined);
const { userPath, loader } = makeLoader();
writeConfig(userPath, [
{ pattern: "{configHome}/my-pi/**", label: "xdg" },
{ pattern: "~/secret.txt", label: "home" },
]);
expect(loader.getProtectedWrites()).toEqual([
{ pattern: join(homedir(), ".config", "my-pi/**"), label: "xdg" },
{ pattern: join(homedir(), "secret.txt"), label: "home" },
]);
});
it("returns empty rules when no protectedWrites key exists", () => {
const { userPath, loader } = makeLoader();
writeFileSync(
userPath,
`{ "version": 1, "permission": { "*": "allow" } }`,
);
expect(loader.getProtectedWrites()).toEqual([]);
expect(loader.getConfigIssues()).toEqual([]);
});
it("returns empty rules when the file is missing (requests deny anyway)", () => {
const { loader } = makeLoader();
expect(loader.getProtectedWrites()).toEqual([]);
expect(loader.getConfigIssues().join("\n")).toContain("denying all");
});
it("treats a malformed protectedWrites as an invalid file", () => {
const { userPath, loader } = makeLoader();
writeConfig(userPath, { pattern: "/x", label: "missing array" });
expect(loader.loadGlobalConfig().permission).toEqual({ "*": "deny" });
expect(loader.getConfigIssues().join("\n")).toContain(
"protectedWrites",
);
});
it("keeps the last good protectedWrites when the file turns invalid", () => {
const { userPath, loader } = makeLoader();
writeConfig(userPath, [
{ pattern: "/vault/auth.json", label: "Pi 凭据" },
]);
loader.loadGlobalConfig();
writeFileSync(userPath, "{ definitely not json at all");
loader.loadGlobalConfig();
expect(loader.getProtectedWrites()).toEqual([
{ pattern: "/vault/auth.json", label: "Pi 凭据" },
]);
expect(loader.getConfigIssues().join("\n")).toContain("Failed to load");
});
});
});
@@ -8,7 +8,6 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { getGlobalConfigPath } from "#src/config-paths";
import { PermissionManager, type PolicyLoader } from "#src/permission-manager";
import type { ResolvedPolicyPaths } from "#src/policy-loader";
import type { Rule } from "#src/rule";
@@ -231,7 +230,7 @@ export function createAgentDirHarness(opts: {
const agentDir = join(baseDir, "agent");
const cwd = join(baseDir, "project");
const globalConfigPath = getGlobalConfigPath(agentDir);
const globalConfigPath = join(agentDir, "pi-permission-policy.jsonc");
mkdirSync(dirname(globalConfigPath), {
recursive: true,
});
@@ -1,9 +1,8 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getGlobalConfigPath } from "#src/config-paths";
import piPermissionSystemExtension from "#src/index";
import type {
PermissionDecisionEvent,
@@ -213,18 +212,26 @@ describe("emitDecisionEvent", () => {
describe("piPermissionSystemExtension ready event wiring", () => {
let baseDir: string;
let originalAgentDir: string | undefined;
// Policy and hard-guard files are injected through the factory options so
// the bundle `config/` files are never touched.
let policyPath: string;
let hardGuardsPath: string;
beforeEach(() => {
baseDir = mkdtempSync(join(tmpdir(), "pi-perm-events-test-"));
originalAgentDir = process.env.PI_CODING_AGENT_DIR;
const globalConfigPath = getGlobalConfigPath(baseDir);
mkdirSync(dirname(globalConfigPath), { recursive: true });
policyPath = join(baseDir, "pi-permission-policy.jsonc");
hardGuardsPath = join(baseDir, "pi-permission-hard-guards.jsonc");
mkdirSync(join(baseDir, "agents"), { recursive: true });
writeFileSync(
globalConfigPath,
policyPath,
`${JSON.stringify({ permission: { "*": "ask" } })}\n`,
"utf8",
);
writeFileSync(
hardGuardsPath,
JSON.stringify({ version: 1, permission: { "*": "allow" } }),
);
process.env.PI_CODING_AGENT_DIR = baseDir;
});
@@ -255,7 +262,10 @@ describe("piPermissionSystemExtension ready event wiring", () => {
setActiveTools: vi.fn(),
registerProvider: vi.fn(),
events: { emit: emitSpy, on: vi.fn().mockReturnValue(() => undefined) },
} as never);
} as never, {
configPath: policyPath,
hardGuardConfigPath: hardGuardsPath,
});
// ready is not emitted at load — only after session_start publishes.
expect(
@@ -1112,12 +1112,16 @@ describe("cross-cutting path surface — home-expanded values", () => {
// ---------------------------------------------------------------------------
test("project policy files are ignored", () => {
const { agentDir, cwd, cleanup } = createAgentDirHarness({
const { agentDir, cwd, globalConfigPath, cleanup } = createAgentDirHarness({
globalPermission: { "*": "ask", bash: "deny" },
projectPermission: { bash: "allow" },
});
try {
const manager = new PermissionManager({ agentDir });
const manager = new PermissionManager({
globalConfigPath,
agentsDir: join(agentDir, "agents"),
projectAgentsDir: join(cwd, ".pi", "agents"),
});
manager.configureForCwd(cwd);
expect(checkTool(manager, "bash", { command: "echo hi" }).state).toBe(
"deny",
@@ -1898,27 +1902,17 @@ permission:
// PI_CODING_AGENT_DIR support — moved from catch-all (#342)
// ---------------------------------------------------------------------------
test("PermissionManager reads config from PI_CODING_AGENT_DIR when set", () => {
test("PermissionManager reads the bundle-level policy regardless of PI_CODING_AGENT_DIR", () => {
// The bundle config/ directory is the single source of truth; the agent dir
// env var no longer influences the ordinary-policy location.
const baseDir = mkdtempSync(join(tmpdir(), "pi-permission-system-envdir-"));
const agentsDir = join(baseDir, "agents");
const newConfigPath = getGlobalConfigPath(baseDir);
mkdirSync(agentsDir, { recursive: true });
mkdirSync(dirname(newConfigPath), { recursive: true });
const config: ScopeConfig = {
permission: { "*": "deny", read: "allow" },
};
writeFileSync(newConfigPath, JSON.stringify(config), "utf8");
const original = process.env.PI_CODING_AGENT_DIR;
process.env.PI_CODING_AGENT_DIR = baseDir;
try {
const manager = new PermissionManager();
const result = checkTool(manager, "read", {});
expect(result.state).toBe("allow");
const result2 = checkTool(manager, "write", {});
expect(result2.state).toBe("deny");
const resolved = manager.getResolvedPolicyPaths();
expect(resolved.globalConfigPath).toBe(getGlobalConfigPath());
expect(resolved.globalConfigPath).not.toContain(baseDir);
} finally {
if (original !== undefined) {
process.env.PI_CODING_AGENT_DIR = original;
@@ -69,9 +69,9 @@ describe("bundled policy seed", () => {
describe("backward compatibility with a deployed config", () => {
it("still accepts the retired annotators key and ignores it", () => {
// The bundle never overwrites a deployed policy.jsonc, so a machine that
// ran the annotator era keeps `annotators` in its file. Rejecting it
// would fail that machine closed instead of ignoring the key.
// A config file saved by an older bundle revision may still carry the
// retired `annotators` key. Rejecting it would fail that file closed
// instead of ignoring the key.
const result = unifiedConfigSchema.safeParse({
annotators: ["model-explanation"],
});
@@ -1,8 +1,7 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { getGlobalConfigPath } from "#src/config-paths";
import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
import piPermissionSystemExtension from "#src/index";
import type { ScopeConfig } from "#src/types";
@@ -18,22 +17,30 @@ type MockHandler = (
describe("session_start handler consolidation", () => {
let baseDir: string;
let originalAgentDir: string | undefined;
// Policy and hard-guard files are injected through the factory options so
// the bundle `config/` files are never touched.
let policyPath: string;
let hardGuardsPath: string;
beforeEach(() => {
baseDir = mkdtempSync(join(tmpdir(), "pi-permission-session-start-"));
originalAgentDir = process.env.PI_CODING_AGENT_DIR;
const globalConfigPath = getGlobalConfigPath(baseDir);
policyPath = join(baseDir, "pi-permission-policy.jsonc");
hardGuardsPath = join(baseDir, "pi-permission-hard-guards.jsonc");
mkdirSync(join(baseDir, "agents"), { recursive: true });
mkdirSync(dirname(globalConfigPath), { recursive: true });
const config: ScopeConfig = {
permission: { "*": "ask" },
};
writeFileSync(
globalConfigPath,
policyPath,
`${JSON.stringify({ ...DEFAULT_EXTENSION_CONFIG, ...config }, null, 2)}\n`,
"utf8",
);
writeFileSync(
hardGuardsPath,
JSON.stringify({ version: 1, permission: { "*": "allow" } }),
);
process.env.PI_CODING_AGENT_DIR = baseDir;
});
@@ -63,7 +70,10 @@ describe("session_start handler consolidation", () => {
emit: (): void => {},
on: (): (() => void) => () => undefined,
},
} as never);
} as never, {
configPath: policyPath,
hardGuardConfigPath: hardGuardsPath,
});
const sessionStartHandlers = registrations.filter(
(r) => r.name === "session_start",
@@ -87,7 +97,10 @@ describe("session_start handler consolidation", () => {
emit: (): void => {},
on: (): (() => void) => () => undefined,
},
} as never);
} as never, {
configPath: policyPath,
hardGuardConfigPath: hardGuardsPath,
});
const sessionStartHandlers = registrations.filter(
(r) => r.name === "session_start",