@gotgenes/pi-permission-system 23.0.0 → 23.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/docs/configuration.md +48 -14
- package/docs/troubleshooting.md +5 -0
- package/package.json +1 -1
- package/src/access-intent/access-path.ts +4 -12
- package/src/authority/forwarding-io.ts +11 -2
- package/src/extension-config.ts +9 -2
- package/src/json-safe-stringify.ts +54 -0
- package/src/log-file-permissions.ts +34 -0
- package/src/log-redaction.ts +44 -0
- package/src/logging.ts +18 -31
- package/src/path-normalizer.ts +7 -10
- package/src/tool-input-preview.ts +18 -2
- package/src/tool-preview-formatter.ts +6 -2
- package/src/wildcard-matcher.ts +39 -14
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [23.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v23.0.1...pi-permission-system-v23.0.2) (2026-07-26)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** create forwarding request files owner-only ([8c77c72](https://github.com/gotgenes/pi-packages/commit/8c77c7228a52d3b57397c7d53d301d002087d92f)), closes [#647](https://github.com/gotgenes/pi-packages/issues/647)
|
|
14
|
+
* **pi-permission-system:** create permission logs owner-only ([6043cf8](https://github.com/gotgenes/pi-packages/commit/6043cf81bbe6332290d2d6bc8882ba1eadf181cc)), closes [#647](https://github.com/gotgenes/pi-packages/issues/647)
|
|
15
|
+
* **pi-permission-system:** mask sensitive-keyed values in permission logs ([05cb12a](https://github.com/gotgenes/pi-packages/commit/05cb12a1c85e3030f6de3a4ed20cf1cd150e2d56)), closes [#647](https://github.com/gotgenes/pi-packages/issues/647)
|
|
16
|
+
* **pi-permission-system:** redact generic tool input in the review log ([2035fb2](https://github.com/gotgenes/pi-packages/commit/2035fb2276efd75daa8d1f4e6e2af8e67763a9f8)), closes [#647](https://github.com/gotgenes/pi-packages/issues/647)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
### Documentation
|
|
20
|
+
|
|
21
|
+
* **pi-permission-system:** link ADR 0010 by absolute URL from shipped docs ([0384af6](https://github.com/gotgenes/pi-packages/commit/0384af6483bd1a2d7f00a5a91efbe241c6d6898f)), closes [#647](https://github.com/gotgenes/pi-packages/issues/647)
|
|
22
|
+
* **pi-permission-system:** record ADR 0010 on permission-log secret exposure ([c13b48a](https://github.com/gotgenes/pi-packages/commit/c13b48a9a14635d25af18a2c0f70bf765141a623)), closes [#647](https://github.com/gotgenes/pi-packages/issues/647)
|
|
23
|
+
|
|
24
|
+
## [23.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v23.0.0...pi-permission-system-v23.0.1) (2026-07-25)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Bug Fixes
|
|
28
|
+
|
|
29
|
+
* **pi-permission-system:** fold separators on both sides of a win32 path match ([50e2ac0](https://github.com/gotgenes/pi-packages/commit/50e2ac0dd66b4b308a676849a09e3fff59e754be)), closes [#653](https://github.com/gotgenes/pi-packages/issues/653)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
### Documentation
|
|
33
|
+
|
|
34
|
+
* **pi-permission-system:** record the symmetric win32 separator fold ([e2eea21](https://github.com/gotgenes/pi-packages/commit/e2eea21e0aac1212f46be9680fb59146b90c68f7)), closes [#653](https://github.com/gotgenes/pi-packages/issues/653)
|
|
35
|
+
|
|
8
36
|
## [23.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v22.0.0...pi-permission-system-v23.0.0) (2026-07-24)
|
|
9
37
|
|
|
10
38
|
|
package/docs/configuration.md
CHANGED
|
@@ -98,16 +98,16 @@ This clamp is deny-preserving and, like `yoloMode`, applied at composition; when
|
|
|
98
98
|
|
|
99
99
|
## Runtime Knobs
|
|
100
100
|
|
|
101
|
-
| Key | Default | Description
|
|
102
|
-
| --------------------------- | ------- |
|
|
103
|
-
| `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl`
|
|
104
|
-
| `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl
|
|
105
|
-
| `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled
|
|
106
|
-
| `doublePressToConfirm` | `true` | Requires a confirming second press of a decision hotkey in the inline TUI dialog (see below). TUI sessions only; set to `false` for single-press.
|
|
107
|
-
| `toolInputPreviewMaxLength` | `200` | Max characters of inline JSON shown in permission prompts for tool inputs. Omit to use the default. Set to a large value to disable truncation.
|
|
108
|
-
| `toolTextSummaryMaxLength` | `80` | Max characters of inline pattern/path summaries (grep patterns, find globs, ls paths) in permission prompts. Omit to use the default.
|
|
109
|
-
| `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME` expansion and wildcard patterns (`*`, `?`).
|
|
110
|
-
| `authorizerChain` | `[]` | Ordered names of registered live-authority chain links to consult before the terminal authorizer (see [Authorizer chain](#authorizer-chain--case-by-case-decision-links)).
|
|
101
|
+
| Key | Default | Description |
|
|
102
|
+
| --------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
103
|
+
| `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl` |
|
|
104
|
+
| `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl`. Records bash command strings verbatim — see [Log file sensitivity](#log-file-sensitivity) |
|
|
105
|
+
| `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
|
|
106
|
+
| `doublePressToConfirm` | `true` | Requires a confirming second press of a decision hotkey in the inline TUI dialog (see below). TUI sessions only; set to `false` for single-press. |
|
|
107
|
+
| `toolInputPreviewMaxLength` | `200` | Max characters of inline JSON shown in permission prompts for tool inputs. Omit to use the default. Set to a large value to disable truncation. |
|
|
108
|
+
| `toolTextSummaryMaxLength` | `80` | Max characters of inline pattern/path summaries (grep patterns, find globs, ls paths) in permission prompts. Omit to use the default. |
|
|
109
|
+
| `piInfrastructureReadPaths` | `[]` | Extra directories to auto-allow for reads, bypassing the `external_directory` gate. Supports `~`/`$HOME` expansion and wildcard patterns (`*`, `?`). |
|
|
110
|
+
| `authorizerChain` | `[]` | Ordered names of registered live-authority chain links to consult before the terminal authorizer (see [Authorizer chain](#authorizer-chain--case-by-case-decision-links)). |
|
|
111
111
|
|
|
112
112
|
Both logs write to `~/.pi/agent/extensions/pi-permission-system/logs/`.
|
|
113
113
|
No debug output is printed to the terminal.
|
|
@@ -618,15 +618,18 @@ Infrastructure directories include:
|
|
|
618
618
|
Write tools (`write`, `edit`) to infrastructure paths are **not** auto-allowed and still go through the gate.
|
|
619
619
|
|
|
620
620
|
On Windows, path matching for `external_directory`, `path`, and the path-bearing tools is case-insensitive and tolerant of either separator (`\` or `/`), matching the case-insensitive filesystem.
|
|
621
|
-
|
|
622
|
-
|
|
621
|
+
The separator folding applies to the rule pattern **and** to the value it is matched against, so either side may be written with either separator.
|
|
622
|
+
A mixed-case allow override such as `~/AppData/Roaming/npm/node_modules/@earendil-works/pi-coding-agent/*` therefore matches a lowercased, backslash-normalized path value, and a forward-slash rule such as `"/dev/null"` matches a value that is also spelled with forward slashes.
|
|
623
|
+
POSIX matching remains case-sensitive and does not fold separators.
|
|
623
624
|
|
|
624
625
|
#### Git Bash / MSYS paths on Windows
|
|
625
626
|
|
|
626
627
|
On Windows, Pi executes bash commands through Git Bash, so a bash token that looks like a POSIX absolute path carries MSYS mount semantics rather than native `node:path.win32` semantics.
|
|
627
628
|
The `external_directory` and `path` gates interpret bash tokens accordingly (tool-input paths for `read`/`write`/`edit` keep native Windows semantics, since those tools resolve them through Node's filesystem):
|
|
628
629
|
|
|
629
|
-
- The safe device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are recognized as MSYS devices
|
|
630
|
+
- The safe device paths (`/dev/null`, `/dev/stdin`, `/dev/stdout`, `/dev/stderr`) are recognized as MSYS devices rather than filesystem paths, so they never trigger the `external_directory` gate — the same exclusion that holds on POSIX.
|
|
631
|
+
The cross-cutting `path` surface still governs them on both platforms: if a `path` rule matches the token, it decides.
|
|
632
|
+
A device is therefore allow-listed the way any other path is, written as typed — `path: { "/dev/null": "allow" }`.
|
|
630
633
|
- MSYS drive mounts (`/c/…`, `/d/…`) are translated to their Windows equivalent (`C:\…`), so a project file referenced through a mount is matched against its real Windows path and an in-CWD mount is not flagged.
|
|
631
634
|
- Every other POSIX-absolute token (`/tmp/foo`, `/usr/bin`) has an install-dependent target this extension cannot resolve deterministically (Git Bash mounts `/tmp` to `%TEMP%`, MSYS2 to its own root), so it is treated as an external path matched and displayed exactly as typed, never rewritten to `C:\tmp\foo`.
|
|
632
635
|
|
|
@@ -900,7 +903,38 @@ Additional behaviors:
|
|
|
900
903
|
- The narrowed prompt is recomputed and returned on every turn but is byte-stable for a stable policy/agent, so the provider's prompt cache (tools + system prefix) is preserved rather than rewritten each turn
|
|
901
904
|
- Extension-provided tools like `task`, `mcp`, and third-party tools are handled by exact registered name
|
|
902
905
|
- Generic extension-tool approval prompts include a bounded input preview; built-in file tools use concise human-readable summaries
|
|
903
|
-
- Permission review logs include bounded `toolInputPreview` values for non-bash/non-MCP tool calls
|
|
906
|
+
- Permission review logs include bounded `toolInputPreview` values for non-bash/non-MCP tool calls, with sensitive-keyed values masked (see [Log file sensitivity](#log-file-sensitivity))
|
|
907
|
+
|
|
908
|
+
---
|
|
909
|
+
|
|
910
|
+
## Log file sensitivity
|
|
911
|
+
|
|
912
|
+
The review log is enabled by default and records what the agent actually did, which means it records payload as well as decisions: the complete bash command string for every bash decision, and a bounded JSON preview of the tool input for other tools.
|
|
913
|
+
The debug log carries the same payload when `debugLog` is on.
|
|
914
|
+
|
|
915
|
+
Two protections apply.
|
|
916
|
+
|
|
917
|
+
Both logs are created **owner-only** (`0600`, in a `0700` directory), and a log created by an earlier version is tightened on the next write.
|
|
918
|
+
The permission-forwarding request and response files are written the same way.
|
|
919
|
+
This closes the shared-host case: another user on the same machine cannot read them.
|
|
920
|
+
|
|
921
|
+
Values bound to a **sensitive key name** — `authorization`, `token`, `secret`, `password`, `credential`, `cookie`, `api_key`, `private_key`, matched case-insensitively — are masked as `[redacted]` before anything is written.
|
|
922
|
+
So a tool called with `{"authorization": "Bearer …"}` records `{"authorization": "[redacted]"}`.
|
|
923
|
+
|
|
924
|
+
The boundary is worth stating exactly, because it is easy to over-read:
|
|
925
|
+
|
|
926
|
+
> A value bound to a sensitive key name is masked; a secret embedded in a bash command string is not.
|
|
927
|
+
|
|
928
|
+
A command string has no keys, so `deploy --token abc123` is logged verbatim.
|
|
929
|
+
The extension deliberately does not try to guess which parts of a command look secret-shaped — see [ADR 0010] for the measured reasoning.
|
|
930
|
+
|
|
931
|
+
Practical guidance:
|
|
932
|
+
|
|
933
|
+
- Treat both log files as sensitive when sharing them: scrub before pasting into an issue or a chat.
|
|
934
|
+
- Set `"permissionReviewLog": false` (and leave `debugLog` off) for a session that will handle credentials on the command line.
|
|
935
|
+
- Owner-only modes do not protect against anything running as you, including a backup or cloud-sync agent that copies your home directory.
|
|
936
|
+
|
|
937
|
+
[ADR 0010]: https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0010-permission-log-secret-exposure.md
|
|
904
938
|
|
|
905
939
|
---
|
|
906
940
|
|
package/docs/troubleshooting.md
CHANGED
|
@@ -51,3 +51,8 @@ This makes it easy to verify which files the extension actually loaded:
|
|
|
51
51
|
|
|
52
52
|
- If a dangerous action is possible via an allowed tool, policy must explicitly restrict it
|
|
53
53
|
- This is a permission decision layer, not a sandbox — for true isolation see [Agent Sandboxes](https://engine.build/lab/agent-sandboxes)
|
|
54
|
+
- The review log records bash command strings verbatim.
|
|
55
|
+
Log files are created owner-only (`0600`), and values bound to a sensitive key name (`authorization`, `token`, `password`, …) are masked — but a secret embedded in a command string is not.
|
|
56
|
+
See [Log file sensitivity](configuration.md#log-file-sensitivity) and [ADR 0010].
|
|
57
|
+
|
|
58
|
+
[ADR 0010]: https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0010-permission-log-secret-exposure.md
|
package/package.json
CHANGED
|
@@ -120,20 +120,12 @@ export class AccessPath {
|
|
|
120
120
|
* unknown (a relative bash token after a non-literal `cd`).
|
|
121
121
|
*
|
|
122
122
|
* Carries no canonical alias and no absolute resolution — `matchValues()` is
|
|
123
|
-
* `[literal
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* `matchAliases` supplies extra match-only forms that do not change the
|
|
127
|
-
* display value: a win32 Git Bash POSIX absolute carries a backslash-separated
|
|
128
|
-
* alias so the separator-folding path matcher can match a `/tmp/*` rule (#533).
|
|
123
|
+
* `[literal]` (or `[]` when empty) and `boundaryValue()` is `""` — so no
|
|
124
|
+
* spurious absolute or symlink-resolved rule can match (#393).
|
|
129
125
|
*/
|
|
130
|
-
static forLiteral(
|
|
131
|
-
literal: string,
|
|
132
|
-
matchAliases: readonly string[] = [],
|
|
133
|
-
): AccessPath {
|
|
126
|
+
static forLiteral(literal: string): AccessPath {
|
|
134
127
|
if (!literal) return new AccessPath("", [], "");
|
|
135
|
-
|
|
136
|
-
return new AccessPath(literal, aliases, "");
|
|
128
|
+
return new AccessPath(literal, [literal], "");
|
|
137
129
|
}
|
|
138
130
|
|
|
139
131
|
/**
|
|
@@ -18,6 +18,10 @@ import {
|
|
|
18
18
|
type ForwardedSessionApproval,
|
|
19
19
|
type PermissionForwardingLocation,
|
|
20
20
|
} from "#src/authority/permission-forwarding";
|
|
21
|
+
import {
|
|
22
|
+
OWNER_ONLY_DIRECTORY_MODE,
|
|
23
|
+
OWNER_ONLY_FILE_MODE,
|
|
24
|
+
} from "#src/log-file-permissions";
|
|
21
25
|
import type { PermissionUiPromptSource } from "#src/permission-events";
|
|
22
26
|
import type { DebugReviewLogger } from "#src/session-logger";
|
|
23
27
|
|
|
@@ -185,7 +189,7 @@ export function ensureDirectoryExists(
|
|
|
185
189
|
description: string,
|
|
186
190
|
): boolean {
|
|
187
191
|
try {
|
|
188
|
-
mkdirSync(path, { recursive: true });
|
|
192
|
+
mkdirSync(path, { recursive: true, mode: OWNER_ONLY_DIRECTORY_MODE });
|
|
189
193
|
return true;
|
|
190
194
|
} catch (error) {
|
|
191
195
|
logPermissionForwardingError(
|
|
@@ -365,7 +369,12 @@ export function writeJsonFileAtomic(
|
|
|
365
369
|
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
366
370
|
|
|
367
371
|
try {
|
|
368
|
-
|
|
372
|
+
// `rename` preserves the temp file's mode, so setting it here is enough —
|
|
373
|
+
// a response overwriting an existing file also comes through a fresh temp.
|
|
374
|
+
writeFileSync(tempPath, JSON.stringify(value), {
|
|
375
|
+
encoding: "utf-8",
|
|
376
|
+
mode: OWNER_ONLY_FILE_MODE,
|
|
377
|
+
});
|
|
369
378
|
renameSync(tempPath, filePath);
|
|
370
379
|
} catch (error) {
|
|
371
380
|
safeDeleteFile(logger, tempPath, "temporary permission-forwarding");
|
package/src/extension-config.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { mkdirSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
|
|
5
4
|
import type {
|
|
6
5
|
ShellToolsConfig,
|
|
7
6
|
UnifiedPermissionConfig,
|
|
8
7
|
} from "./config-loader";
|
|
8
|
+
import {
|
|
9
|
+
OWNER_ONLY_DIRECTORY_MODE,
|
|
10
|
+
restrictExistingPathToOwner,
|
|
11
|
+
} from "./log-file-permissions";
|
|
9
12
|
|
|
10
13
|
export const EXTENSION_ID = "pi-permission-system";
|
|
11
14
|
|
|
@@ -94,7 +97,11 @@ export function ensurePermissionSystemLogsDirectory(
|
|
|
94
97
|
logsDir: string,
|
|
95
98
|
): string | undefined {
|
|
96
99
|
try {
|
|
97
|
-
|
|
100
|
+
// `recursive` applies the mode to every directory this creates, so a fresh
|
|
101
|
+
// install also gets an owner-only extension config dir. Directories that
|
|
102
|
+
// already exist are untouched by `mkdirSync`, hence the explicit tighten.
|
|
103
|
+
mkdirSync(logsDir, { recursive: true, mode: OWNER_ONLY_DIRECTORY_MODE });
|
|
104
|
+
restrictExistingPathToOwner(logsDir, OWNER_ONLY_DIRECTORY_MODE);
|
|
98
105
|
return undefined;
|
|
99
106
|
} catch (error) {
|
|
100
107
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON serialization that survives the values a permission log actually
|
|
3
|
+
* carries: `Error` instances, `bigint`s, and object graphs with cycles.
|
|
4
|
+
*
|
|
5
|
+
* Lives apart from the JSONL writer because both the log path and the
|
|
6
|
+
* permission-prompt path serialize tool input, and only one of them redacts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Rewrites a value before the standard JSON-safe handling runs.
|
|
11
|
+
* Returning a replacement short-circuits nothing — the replacement itself
|
|
12
|
+
* flows through the `Error` / `bigint` / cycle handling below.
|
|
13
|
+
*/
|
|
14
|
+
export type JsonValueTransform = (key: string, value: unknown) => unknown;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Build a `JSON.stringify` replacer. Each call owns a fresh `seen` set, so a
|
|
18
|
+
* replacer must not be reused across `stringify` calls.
|
|
19
|
+
*/
|
|
20
|
+
export function createJsonSafeReplacer(
|
|
21
|
+
transform?: JsonValueTransform,
|
|
22
|
+
): (key: string, value: unknown) => unknown {
|
|
23
|
+
const seen = new WeakSet<object>();
|
|
24
|
+
|
|
25
|
+
return (key: string, rawValue: unknown): unknown => {
|
|
26
|
+
const value = transform ? transform(key, rawValue) : rawValue;
|
|
27
|
+
|
|
28
|
+
if (value instanceof Error) {
|
|
29
|
+
return {
|
|
30
|
+
name: value.name,
|
|
31
|
+
message: value.message,
|
|
32
|
+
stack: value.stack,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (typeof value === "bigint") {
|
|
37
|
+
return value.toString();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (typeof value === "object" && value !== null) {
|
|
41
|
+
if (seen.has(value)) {
|
|
42
|
+
return "[Circular]";
|
|
43
|
+
}
|
|
44
|
+
seen.add(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return value;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Serialize `value` to JSON, tolerating errors, bigints, and cycles. */
|
|
52
|
+
export function safeJsonStringify(value: unknown): string | undefined {
|
|
53
|
+
return JSON.stringify(value, createJsonSafeReplacer());
|
|
54
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { chmodSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Owner-only POSIX modes for the extension's on-disk artifacts.
|
|
5
|
+
*
|
|
6
|
+
* The permission logs record bash command strings and tool-input previews, and
|
|
7
|
+
* the forwarding files carry the same text between sessions. Left to the
|
|
8
|
+
* process umask they are created world-readable (0644 / 0755 under the common
|
|
9
|
+
* default), which is only acceptable on a single-user host.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const OWNER_ONLY_FILE_MODE = 0o600;
|
|
13
|
+
export const OWNER_ONLY_DIRECTORY_MODE = 0o700;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Best-effort tightening of an existing path's mode.
|
|
17
|
+
*
|
|
18
|
+
* Creation-time modes cover new files, but an installation that predates this
|
|
19
|
+
* hardening already has a world-readable log that no `mode` option will fix —
|
|
20
|
+
* hence the explicit `chmod`.
|
|
21
|
+
*
|
|
22
|
+
* Never throws, and never reports. On Windows `chmod` only toggles the
|
|
23
|
+
* read-only bit and can reject a directory outright; warning about that every
|
|
24
|
+
* session would be noise, since the file there is governed by NTFS ACL
|
|
25
|
+
* inheritance rather than POSIX modes. A hardening failure must also never
|
|
26
|
+
* break the gate, which is the caller's real work.
|
|
27
|
+
*/
|
|
28
|
+
export function restrictExistingPathToOwner(path: string, mode: number): void {
|
|
29
|
+
try {
|
|
30
|
+
chmodSync(path, mode);
|
|
31
|
+
} catch {
|
|
32
|
+
// Intentionally ignored — see above.
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createJsonSafeReplacer } from "./json-safe-stringify";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Key-name redaction for the permission logs.
|
|
5
|
+
*
|
|
6
|
+
* The technique is deliberately structural rather than predictive: a value is
|
|
7
|
+
* masked because of the *name* it is bound to, never because of what it looks
|
|
8
|
+
* like. Value-shape secret detection (provider prefixes, entropy heuristics)
|
|
9
|
+
* was measured against a real 6.7 MB review log and declined — see
|
|
10
|
+
* `docs/decisions/0010-permission-log-secret-exposure.md`.
|
|
11
|
+
*
|
|
12
|
+
* The boundary that follows from this, stated once: a value bound to a
|
|
13
|
+
* sensitive key name is masked; a secret embedded in a bash command string is
|
|
14
|
+
* not, because a command string has no keys.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const REDACTED_PLACEHOLDER = "[redacted]";
|
|
18
|
+
|
|
19
|
+
const SENSITIVE_KEY_PATTERN =
|
|
20
|
+
/authorization|api[-_]?key|secret|token|password|passwd|credential|cookie|private[-_]?key/i;
|
|
21
|
+
|
|
22
|
+
/** True when a log key names a credential-bearing value. */
|
|
23
|
+
export function isSensitiveLogKey(key: string): boolean {
|
|
24
|
+
return SENSITIVE_KEY_PATTERN.test(key);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* `safeJsonStringify` with sensitive-keyed values masked.
|
|
29
|
+
*
|
|
30
|
+
* Masking runs inside the replacer, so the structure beneath a sensitive key
|
|
31
|
+
* is never visited and the traversal's existing cycle guard is reused — one
|
|
32
|
+
* walk, not two. A `null` or `undefined` value is left alone so an absent
|
|
33
|
+
* field does not read as a suppressed one.
|
|
34
|
+
*/
|
|
35
|
+
export function redactedJsonStringify(value: unknown): string | undefined {
|
|
36
|
+
return JSON.stringify(
|
|
37
|
+
value,
|
|
38
|
+
createJsonSafeReplacer((key, currentValue) =>
|
|
39
|
+
currentValue != null && isSensitiveLogKey(key)
|
|
40
|
+
? REDACTED_PLACEHOLDER
|
|
41
|
+
: currentValue,
|
|
42
|
+
),
|
|
43
|
+
);
|
|
44
|
+
}
|
package/src/logging.ts
CHANGED
|
@@ -4,35 +4,11 @@ import {
|
|
|
4
4
|
EXTENSION_ID,
|
|
5
5
|
type PermissionSystemExtensionConfig,
|
|
6
6
|
} from "./extension-config";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
return {
|
|
13
|
-
name: currentValue.name,
|
|
14
|
-
message: currentValue.message,
|
|
15
|
-
stack: currentValue.stack,
|
|
16
|
-
};
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
if (typeof currentValue === "bigint") {
|
|
20
|
-
return currentValue.toString();
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
if (typeof currentValue === "object" && currentValue !== null) {
|
|
24
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- JSON.stringify replacer receives any; currentValue is narrowed to object here
|
|
25
|
-
if (seen.has(currentValue)) {
|
|
26
|
-
return "[Circular]";
|
|
27
|
-
}
|
|
28
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- same as above
|
|
29
|
-
seen.add(currentValue);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// eslint-disable-next-line @typescript-eslint/no-unsafe-return -- JSON.stringify replacer must return any
|
|
33
|
-
return currentValue;
|
|
34
|
-
});
|
|
35
|
-
}
|
|
7
|
+
import {
|
|
8
|
+
OWNER_ONLY_FILE_MODE,
|
|
9
|
+
restrictExistingPathToOwner,
|
|
10
|
+
} from "./log-file-permissions";
|
|
11
|
+
import { redactedJsonStringify } from "./log-redaction";
|
|
36
12
|
|
|
37
13
|
export interface PermissionSystemLogger {
|
|
38
14
|
debug: (
|
|
@@ -56,6 +32,10 @@ export function createPermissionSystemLogger(
|
|
|
56
32
|
options: PermissionSystemLoggerOptions,
|
|
57
33
|
): PermissionSystemLogger {
|
|
58
34
|
const { debugLogPath, reviewLogPath, ensureLogsDirectory } = options;
|
|
35
|
+
// Per-session, so a log inherited from an earlier version is tightened once
|
|
36
|
+
// rather than on every line. Lives in the closure because the factory is
|
|
37
|
+
// re-invoked per session, unlike module scope, which now outlives one.
|
|
38
|
+
const hardened = new Set<string>();
|
|
59
39
|
|
|
60
40
|
const writeLine = (
|
|
61
41
|
stream: "debug" | "review",
|
|
@@ -69,7 +49,7 @@ export function createPermissionSystemLogger(
|
|
|
69
49
|
}
|
|
70
50
|
|
|
71
51
|
try {
|
|
72
|
-
const line =
|
|
52
|
+
const line = redactedJsonStringify({
|
|
73
53
|
timestamp: new Date().toISOString(),
|
|
74
54
|
extension: EXTENSION_ID,
|
|
75
55
|
stream,
|
|
@@ -79,7 +59,14 @@ export function createPermissionSystemLogger(
|
|
|
79
59
|
if (!line) {
|
|
80
60
|
return `Failed to write permission-system ${stream} log '${path}': event could not be serialized.`;
|
|
81
61
|
}
|
|
82
|
-
appendFileSync(path, `${line}\n`,
|
|
62
|
+
appendFileSync(path, `${line}\n`, {
|
|
63
|
+
encoding: "utf-8",
|
|
64
|
+
mode: OWNER_ONLY_FILE_MODE,
|
|
65
|
+
});
|
|
66
|
+
if (!hardened.has(path)) {
|
|
67
|
+
hardened.add(path);
|
|
68
|
+
restrictExistingPathToOwner(path, OWNER_ONLY_FILE_MODE);
|
|
69
|
+
}
|
|
83
70
|
return undefined;
|
|
84
71
|
} catch (error) {
|
|
85
72
|
const message = error instanceof Error ? error.message : String(error);
|
package/src/path-normalizer.ts
CHANGED
|
@@ -62,8 +62,8 @@ export class PathNormalizer {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
/** Build a literal-only AccessPath (unknown base after a non-literal `cd`). */
|
|
65
|
-
forLiteral(literal: string
|
|
66
|
-
return AccessPath.forLiteral(literal
|
|
65
|
+
forLiteral(literal: string): AccessPath {
|
|
66
|
+
return AccessPath.forLiteral(literal);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
/**
|
|
@@ -86,17 +86,14 @@ export class PathNormalizer {
|
|
|
86
86
|
return AccessPath.forDevice(token);
|
|
87
87
|
case "drive-mount":
|
|
88
88
|
return this.forPath(shape.windowsPath, options);
|
|
89
|
-
case "posix-absolute":
|
|
89
|
+
case "posix-absolute":
|
|
90
90
|
// A non-mount POSIX absolute (`/tmp`, `/usr`) has an install-dependent
|
|
91
91
|
// Windows target this package cannot know, so it is kept literal: always
|
|
92
92
|
// external, matched and displayed as typed, never fabricated into
|
|
93
|
-
// `c:\tmp` (#533). The win32 path matcher folds
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
const literal = normalizePathPolicyLiteral(token);
|
|
98
|
-
return this.forLiteral(literal, [literal.replaceAll("/", "\\")]);
|
|
99
|
-
}
|
|
93
|
+
// `c:\tmp` (#533). The win32 path matcher folds separators on both the
|
|
94
|
+
// rule and the value (#653), so a natural `/tmp/*` rule matches the
|
|
95
|
+
// as-typed literal directly.
|
|
96
|
+
return this.forLiteral(normalizePathPolicyLiteral(token));
|
|
100
97
|
case "plain":
|
|
101
98
|
return this.forPath(token, options);
|
|
102
99
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { safeJsonStringify } from "./
|
|
1
|
+
import { safeJsonStringify } from "./json-safe-stringify";
|
|
2
|
+
import { redactedJsonStringify } from "./log-redaction";
|
|
2
3
|
|
|
3
4
|
export const TOOL_INPUT_PREVIEW_MAX_LENGTH = 200;
|
|
4
5
|
export const TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH = 1000;
|
|
@@ -24,8 +25,23 @@ export function formatCount(
|
|
|
24
25
|
return `${value} ${value === 1 ? singular : plural}`;
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/** Serialize tool input for display in a permission prompt, unredacted. */
|
|
27
29
|
export function serializeToolInputPreview(input: unknown): string {
|
|
28
|
-
|
|
30
|
+
return normalizeSerializedPreview(safeJsonStringify(input));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Serialize tool input for the review log, masking sensitive-keyed values.
|
|
35
|
+
*
|
|
36
|
+
* The log path needs its own entry point because the input is flattened to a
|
|
37
|
+
* string here — by the time it reaches the JSONL writer its keys are gone, so
|
|
38
|
+
* that boundary's redaction pass can no longer see them.
|
|
39
|
+
*/
|
|
40
|
+
export function serializeRedactedToolInputPreview(input: unknown): string {
|
|
41
|
+
return normalizeSerializedPreview(redactedJsonStringify(input));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function normalizeSerializedPreview(serialized: string | undefined): string {
|
|
29
45
|
if (!serialized || serialized === "{}" || serialized === "null") {
|
|
30
46
|
return "";
|
|
31
47
|
}
|
|
@@ -2,6 +2,7 @@ import { classifyToolKind, isMcpCheck } from "./access-intent/tool-kind";
|
|
|
2
2
|
import type { PermissionSystemExtensionConfig } from "./extension-config";
|
|
3
3
|
import type { ToolInputFormatterLookup } from "./tool-input-formatter-registry";
|
|
4
4
|
import {
|
|
5
|
+
serializeRedactedToolInputPreview,
|
|
5
6
|
serializeToolInputPreview,
|
|
6
7
|
TOOL_INPUT_LOG_PREVIEW_MAX_LENGTH,
|
|
7
8
|
TOOL_INPUT_PREVIEW_MAX_LENGTH,
|
|
@@ -145,9 +146,12 @@ export class ToolPreviewFormatter {
|
|
|
145
146
|
|
|
146
147
|
// ── Log formatting ──────────────────────────────────────────────────────
|
|
147
148
|
|
|
148
|
-
/**
|
|
149
|
+
/**
|
|
150
|
+
* Serialize `input` to inline JSON and truncate at
|
|
151
|
+
* `toolInputLogPreviewMaxLength`, masking sensitive-keyed values.
|
|
152
|
+
*/
|
|
149
153
|
formatGenericToolInputForLog(input: unknown): string | undefined {
|
|
150
|
-
const inline =
|
|
154
|
+
const inline = serializeRedactedToolInputPreview(input);
|
|
151
155
|
return inline
|
|
152
156
|
? `input ${truncateInlineText(inline, this.options.toolInputLogPreviewMaxLength)}`
|
|
153
157
|
: undefined;
|
package/src/wildcard-matcher.ts
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import { expandHomePath } from "./expand-home";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
}
|
|
3
|
+
/**
|
|
4
|
+
* A pattern compiled once for repeated matching.
|
|
5
|
+
*
|
|
6
|
+
* Matching is a method rather than an exposed `RegExp` so that both halves of
|
|
7
|
+
* the {@link WildcardMatchOptions} fold stay together: the compiled regex
|
|
8
|
+
* carries the pattern-side folding, and {@link matches} applies the same
|
|
9
|
+
* folding to the value. A caller holding the raw regex could apply one without
|
|
10
|
+
* the other, which is exactly the asymmetry that made forward-slash path rules
|
|
11
|
+
* inert on Windows (#653).
|
|
12
|
+
*/
|
|
13
|
+
export interface CompiledWildcardPattern<TState> {
|
|
14
|
+
readonly pattern: string;
|
|
15
|
+
readonly state: TState;
|
|
16
|
+
matches(value: string): boolean;
|
|
17
|
+
}
|
|
8
18
|
|
|
9
19
|
export type WildcardPatternMatch<TState> = {
|
|
10
20
|
state: TState;
|
|
@@ -17,8 +27,11 @@ export type WildcardPatternMatch<TState> = {
|
|
|
17
27
|
*
|
|
18
28
|
* - `caseInsensitive` compiles the pattern with the `i` flag so a mixed-case
|
|
19
29
|
* pattern matches a lowercased (canonicalized) path value.
|
|
20
|
-
* - `windowsSeparators` rewrites `/` to `\` in the expanded pattern
|
|
21
|
-
*
|
|
30
|
+
* - `windowsSeparators` rewrites `/` to `\` in both the expanded pattern and
|
|
31
|
+
* the matched value, so two spellings of the same path match regardless of
|
|
32
|
+
* which separator either side was written with. Folding only the pattern
|
|
33
|
+
* leaves every forward-slash value (a Git Bash device, an as-typed literal)
|
|
34
|
+
* unmatchable (#653).
|
|
22
35
|
*/
|
|
23
36
|
export interface WildcardMatchOptions {
|
|
24
37
|
caseInsensitive?: boolean;
|
|
@@ -34,10 +47,7 @@ export function compileWildcardPattern<TState>(
|
|
|
34
47
|
state: TState,
|
|
35
48
|
options?: WildcardMatchOptions,
|
|
36
49
|
): CompiledWildcardPattern<TState> {
|
|
37
|
-
|
|
38
|
-
if (options?.windowsSeparators) {
|
|
39
|
-
expanded = expanded.replaceAll("/", "\\");
|
|
40
|
-
}
|
|
50
|
+
const expanded = foldSeparators(expandHomePath(pattern), options);
|
|
41
51
|
let escaped = expanded
|
|
42
52
|
.split("*")
|
|
43
53
|
.map((part) => escapeRegExp(part).replaceAll("\\?", "."))
|
|
@@ -50,10 +60,15 @@ export function compileWildcardPattern<TState>(
|
|
|
50
60
|
escaped = `${escaped.slice(0, -3)}( .*)?`;
|
|
51
61
|
}
|
|
52
62
|
|
|
63
|
+
const regex = new RegExp(
|
|
64
|
+
`^${escaped}$`,
|
|
65
|
+
options?.caseInsensitive ? "si" : "s",
|
|
66
|
+
);
|
|
67
|
+
|
|
53
68
|
return {
|
|
54
69
|
pattern,
|
|
55
70
|
state,
|
|
56
|
-
|
|
71
|
+
matches: (value) => regex.test(foldSeparators(value, options)),
|
|
57
72
|
};
|
|
58
73
|
}
|
|
59
74
|
|
|
@@ -75,7 +90,7 @@ export function findCompiledWildcardMatch<TState>(
|
|
|
75
90
|
patterns: readonly CompiledWildcardPattern<TState>[],
|
|
76
91
|
name: string,
|
|
77
92
|
): WildcardPatternMatch<TState> | null {
|
|
78
|
-
const match = patterns.findLast((p) => p.
|
|
93
|
+
const match = patterns.findLast((p) => p.matches(name));
|
|
79
94
|
if (match === undefined) return null;
|
|
80
95
|
return {
|
|
81
96
|
state: match.state,
|
|
@@ -95,7 +110,17 @@ export function wildcardMatch(
|
|
|
95
110
|
value: string,
|
|
96
111
|
options?: WildcardMatchOptions,
|
|
97
112
|
): boolean {
|
|
98
|
-
return compileWildcardPattern(pattern, null, options).
|
|
113
|
+
return compileWildcardPattern(pattern, null, options).matches(value);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Apply the `windowsSeparators` half of the fold to one operand.
|
|
118
|
+
*
|
|
119
|
+
* Called for the pattern at compile time and for the value at match time —
|
|
120
|
+
* the fold is an equivalence relation, so both sides must pass through it.
|
|
121
|
+
*/
|
|
122
|
+
function foldSeparators(value: string, options?: WildcardMatchOptions): string {
|
|
123
|
+
return options?.windowsSeparators ? value.replaceAll("/", "\\") : value;
|
|
99
124
|
}
|
|
100
125
|
|
|
101
126
|
export function findCompiledWildcardMatchForNames<TState>(
|