@gotgenes/pi-permission-system 32.0.1 → 32.0.3
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 +25 -0
- package/README.md +2 -1
- package/docs/configuration.md +16 -5
- package/docs/troubleshooting.md +13 -11
- package/package.json +1 -1
- package/src/access-intent/bash/parser.ts +2 -0
- package/src/authority/forwarding-io.ts +47 -2
- package/src/authority/transient-fs-retry.ts +118 -0
- package/src/logging/command-redaction.ts +241 -0
- package/src/logging/log-field-cap.ts +3 -14
- package/src/logging/log-redaction.ts +32 -10
- package/src/logging/logging.ts +33 -14
- package/src/value-guards.ts +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,31 @@ 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
|
+
## [32.0.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.2...pi-permission-system-v32.0.3) (2026-09-15)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** mask a value bound to a bare or suffixed key name ([f23b8b9](https://github.com/gotgenes/pi-packages/commit/f23b8b93ceafa732ffe9d5484e4a935094d06754)), closes [#920](https://github.com/gotgenes/pi-packages/issues/920)
|
|
14
|
+
* **pi-permission-system:** stop writing a named secret into the permission logs ([cf4f370](https://github.com/gotgenes/pi-packages/commit/cf4f370d192d778e600a253331bbf6e42b3cd758)), closes [#920](https://github.com/gotgenes/pi-packages/issues/920)
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* **pi-permission-system:** restate the log-redaction boundary as name-structural ([958cba1](https://github.com/gotgenes/pi-packages/commit/958cba124a1b799a7e6af959440a26d7cf722934)), closes [#920](https://github.com/gotgenes/pi-packages/issues/920)
|
|
19
|
+
* **pi-permission-system:** correct the review-log knob's unredacted claim ([0664a6c](https://github.com/gotgenes/pi-packages/commit/0664a6cf6fbf3e75c4a6864cc582716dd6d15a26)), closes [#920](https://github.com/gotgenes/pi-packages/issues/920)
|
|
20
|
+
|
|
21
|
+
## [32.0.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.1...pi-permission-system-v32.0.2) (2026-09-11)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Bug Fixes
|
|
25
|
+
|
|
26
|
+
* **pi-permission-system:** keep a forwarded write when a file lock blocks the atomic rename ([d901116](https://github.com/gotgenes/pi-packages/commit/d9011165203b130675a3affd20a2792f7abd7105)), closes [#914](https://github.com/gotgenes/pi-packages/issues/914)
|
|
27
|
+
* **pi-permission-system:** create a forwarding directory blocked by a transient file lock ([245683c](https://github.com/gotgenes/pi-packages/commit/245683c9153712d8991ceb21ece7884b2fb12f2a)), closes [#914](https://github.com/gotgenes/pi-packages/issues/914)
|
|
28
|
+
|
|
29
|
+
### Documentation
|
|
30
|
+
|
|
31
|
+
* **pi-permission-system:** record the transient filesystem retry ([eb7ab58](https://github.com/gotgenes/pi-packages/commit/eb7ab58cfd79d15de77bafa6afe8813e9dff7b72)), closes [#914](https://github.com/gotgenes/pi-packages/issues/914)
|
|
32
|
+
|
|
8
33
|
## [32.0.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.0...pi-permission-system-v32.0.1) (2026-09-11)
|
|
9
34
|
|
|
10
35
|
|
package/README.md
CHANGED
|
@@ -177,7 +177,8 @@ Hardening the gates against bypass, fail-closed corrections (breaking ones inclu
|
|
|
177
177
|
- _Permissive defaults, trust profiles, or workflow presets._
|
|
178
178
|
Your risk profile is not knowable from here, so defaults are least-privilege and common policies ship as documented recipes rather than preset keywords.
|
|
179
179
|
- _Guessing what is sensitive._
|
|
180
|
-
No built-in secret denylist, and log redaction is
|
|
180
|
+
No built-in secret denylist, and log redaction is name-structural rather than predictive: a value is masked because of the name that binds it — a log key, a shell variable, a request header field — never because of what it looks like.
|
|
181
|
+
A redactor that guesses invites treating the log as safe to share.
|
|
181
182
|
- _Model judgment in the core._
|
|
182
183
|
This package makes no LLM call and holds no model config; model-assisted judging attaches as a chain link over the authorizer seam instead.
|
|
183
184
|
A link decides nothing until you name it in `authorizerChain`, and its `allow` on an excluded surface is downgraded to `defer`.
|
package/docs/configuration.md
CHANGED
|
@@ -100,7 +100,7 @@ This clamp is deny-preserving and, like `yoloMode`, applied at composition; when
|
|
|
100
100
|
| Key | Default | Description |
|
|
101
101
|
| --------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
102
102
|
| `debugLog` | `false` | Enables verbose diagnostic logging to `logs/pi-permission-system-debug.jsonl` |
|
|
103
|
-
| `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl`. Records bash command strings
|
|
103
|
+
| `permissionReviewLog` | `true` | Enables the permission request/denial review log at `logs/pi-permission-system-permission-review.jsonl`. Records bash command strings, masked only where a name binds the secret — see [Log file sensitivity](#log-file-sensitivity) |
|
|
104
104
|
| `yoloMode` | `false` | Auto-approves `ask` results instead of prompting when yolo mode is enabled |
|
|
105
105
|
| `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. |
|
|
106
106
|
| `forwardingTimeoutMs` | `600000` | How long a subagent waits for the parent session to answer a forwarded permission request, in milliseconds. A child whose parent is not draining its inbox gives up in ~2 s regardless, whether that parent runs in this process or its own. |
|
|
@@ -1197,19 +1197,30 @@ Both logs are created **owner-only** (`0600`, in a `0700` directory), and a log
|
|
|
1197
1197
|
The permission-forwarding request and response files are written the same way.
|
|
1198
1198
|
This closes the shared-host case: another user on the same machine cannot read them.
|
|
1199
1199
|
|
|
1200
|
-
Values bound to a **sensitive
|
|
1200
|
+
Values bound to a **sensitive name** — `authorization`, `token`, `secret`, `password`, `credential`, `cookie`, and a bare or suffixed `key` (`api_key`, `private_key`, `OPENROUTER_KEY`, `apiKey`), matched case-insensitively — are masked as `[redacted]` before anything is written.
|
|
1201
1201
|
So a tool called with `{"authorization": "Bearer …"}` records `{"authorization": "[redacted]"}`.
|
|
1202
1202
|
|
|
1203
|
+
A bash command binds values to names too, and the same predicate answers for those.
|
|
1204
|
+
The command is parsed, and a value is masked when it is bound to a sensitive name by a shell assignment or a request header field:
|
|
1205
|
+
|
|
1206
|
+
```text
|
|
1207
|
+
KEY="sk-or-v1-…" curl https://x → KEY=[redacted] curl https://x
|
|
1208
|
+
env MY_KEY=… deploy → env MY_KEY=[redacted] deploy
|
|
1209
|
+
curl -H "Authorization: Bearer sk-…" → curl -H "Authorization:[redacted]"
|
|
1210
|
+
```
|
|
1211
|
+
|
|
1203
1212
|
The boundary is worth stating exactly, because it is easy to over-read:
|
|
1204
1213
|
|
|
1205
|
-
> A value bound to a sensitive
|
|
1214
|
+
> A value bound to a sensitive name is masked — whether the name is a log key, a shell variable, or a request header field.
|
|
1215
|
+
> A secret with no name bound to it, such as one typed as a `grep` pattern, is not.
|
|
1206
1216
|
|
|
1207
|
-
|
|
1217
|
+
So `grep -r "sk-ant-…" .` and `deploy --token abc123` are both logged unredacted: the first binds the secret to nothing, and the second binds it to a flag rather than a name.
|
|
1208
1218
|
The extension deliberately does not try to guess which parts of a command look secret-shaped — see [ADR 0010] for the measured reasoning.
|
|
1219
|
+
A command the parser could not fully resolve, and a secret inside an inline-shell payload (`bash -c '…'`) or a heredoc body, are masked only as far as the parse reached.
|
|
1209
1220
|
|
|
1210
1221
|
Every value the **review** log writes is narrowed to `reviewLogFieldMaxWidth` (1000 characters by default) and marked with an ellipsis, so a single pathological command cannot put tens of kilobytes in one entry.
|
|
1211
1222
|
This is a length bound, not redaction: it never inspects a value to decide what to hide, and it applies to every field alike.
|
|
1212
|
-
The two compose — a
|
|
1223
|
+
The two compose, and masking runs first — a sensitively-named value is masked whole however long it was, and the cap never shortens one.
|
|
1213
1224
|
The debug log is left unbounded, since it is opt-in and exists to be read in full.
|
|
1214
1225
|
|
|
1215
1226
|
Practical guidance:
|
package/docs/troubleshooting.md
CHANGED
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## Common Issues
|
|
4
4
|
|
|
5
|
-
| Problem
|
|
6
|
-
|
|
|
7
|
-
| Config not applied (everything asks)
|
|
8
|
-
| Per-agent override not applied
|
|
9
|
-
| Tool blocked as unregistered
|
|
10
|
-
| `/skill:<name>` blocked
|
|
11
|
-
| External file path blocked
|
|
12
|
-
| Spurious external-path prompt for `cd <subdir> && grep … ../path`
|
|
13
|
-
| Permission prompt is too verbose
|
|
5
|
+
| Problem | Cause | Solution |
|
|
6
|
+
| ------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
7
|
+
| Config not applied (everything asks) | File not found or parse error | Verify the global config at `~/.pi/agent/extensions/pi-permission-system/config.json` (respects `PI_CODING_AGENT_DIR`); check for trailing commas |
|
|
8
|
+
| Per-agent override not applied | Frontmatter parsing issue | Ensure `---` delimiters at file top; keep YAML simple; restart session |
|
|
9
|
+
| Tool blocked as unregistered | Unknown tool name | Use a registered `mcp` tool for server tools: `{ "tool": "server:tool" }` |
|
|
10
|
+
| `/skill:<name>` blocked | Deny policy or confirmation unavailable | Check merged `skill` policy (global/project/agent layers). `ask` still requires UI or forwarded confirmation. |
|
|
11
|
+
| 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. |
|
|
12
|
+
| 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. |
|
|
13
|
+
| 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. |
|
|
14
|
+
| Windows: `permission_forwarding.error — EPERM … rename` in the logs, and a subagent's tool call refused | An antivirus scanner or the search indexer held a transient handle on a forwarding file | The write is retried automatically for a short window. If it still fails, enable `debugLog` and look for `permission_forwarding.fs_retried` entries — their `attempts` and `code` say whether retrying is helping — then exclude the forwarding directory from the scanner. |
|
|
14
15
|
|
|
15
16
|
## Diagnostic Logging
|
|
16
17
|
|
|
@@ -53,8 +54,9 @@ This makes it easy to verify which files the extension actually loaded:
|
|
|
53
54
|
- This is a permission decision layer, not a sandbox — for true isolation see [Agent Sandboxes](https://engine.build/lab/agent-sandboxes).
|
|
54
55
|
The two are complementary rather than alternatives: a sandbox enforces which paths are in scope and in which direction, while this package decides whether a particular action on an in-scope path may proceed.
|
|
55
56
|
[ADR 0013] §8 records that division and the seam that exports this package's scope decisions to a sandbox launcher.
|
|
56
|
-
- The review log records bash command strings
|
|
57
|
-
Log files are created owner-only (`0600`), and
|
|
57
|
+
- The review log records bash command strings, masked only where a name binds the secret.
|
|
58
|
+
Log files are created owner-only (`0600`), and a value bound to a sensitive name (`authorization`, `token`, `password`, a bare or suffixed `key`, …) is masked — whether the name is a log key, a shell variable, or a request header field.
|
|
59
|
+
A secret with no name bound to it, such as one typed as a `grep` pattern, is not.
|
|
58
60
|
Review-log values are shortened at `reviewLogFieldMaxWidth` (1000 characters by default), which bounds the file's growth but is a length cap, not redaction.
|
|
59
61
|
See [Log file sensitivity](configuration.md#log-file-sensitivity) and [ADR 0010].
|
|
60
62
|
|
package/package.json
CHANGED
|
@@ -15,6 +15,8 @@ export interface TSNode {
|
|
|
15
15
|
readonly text: string;
|
|
16
16
|
/** Absolute byte offset of this node's start in the parsed source. */
|
|
17
17
|
readonly startIndex: number;
|
|
18
|
+
/** Absolute byte offset one past this node's end in the parsed source. */
|
|
19
|
+
readonly endIndex: number;
|
|
18
20
|
readonly childCount: number;
|
|
19
21
|
/** False for anonymous tokens (operators, delimiters); true for named nodes. */
|
|
20
22
|
readonly isNamed: boolean;
|
|
@@ -29,6 +29,10 @@ import {
|
|
|
29
29
|
type ForwardedSessionApproval,
|
|
30
30
|
type PermissionForwardingLocation,
|
|
31
31
|
} from "./permission-forwarding";
|
|
32
|
+
import {
|
|
33
|
+
retryOnTransientFsError,
|
|
34
|
+
type TransientFsRetryRecord,
|
|
35
|
+
} from "./transient-fs-retry";
|
|
32
36
|
|
|
33
37
|
/** Valid `permissions:ui_prompt` source values, for tolerant request reads. */
|
|
34
38
|
const UI_PROMPT_SOURCES = [
|
|
@@ -217,7 +221,14 @@ export function ensureDirectoryExists(
|
|
|
217
221
|
description: string,
|
|
218
222
|
): boolean {
|
|
219
223
|
try {
|
|
220
|
-
|
|
224
|
+
recordFsRetry(
|
|
225
|
+
logger,
|
|
226
|
+
"mkdir",
|
|
227
|
+
path,
|
|
228
|
+
retryOnTransientFsError(() => {
|
|
229
|
+
mkdirSync(path, { recursive: true, mode: OWNER_ONLY_DIRECTORY_MODE });
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
221
232
|
return true;
|
|
222
233
|
} catch (error) {
|
|
223
234
|
logPermissionForwardingError(
|
|
@@ -399,17 +410,51 @@ export function writeJsonFileAtomic(
|
|
|
399
410
|
try {
|
|
400
411
|
// `rename` preserves the temp file's mode, so setting it here is enough —
|
|
401
412
|
// a response overwriting an existing file also comes through a fresh temp.
|
|
413
|
+
// The temp write is deliberately outside the retry: its failure shape is a
|
|
414
|
+
// write-denied directory, which no number of attempts resolves.
|
|
402
415
|
writeFileSync(tempPath, JSON.stringify(value), {
|
|
403
416
|
encoding: "utf-8",
|
|
404
417
|
mode: OWNER_ONLY_FILE_MODE,
|
|
405
418
|
});
|
|
406
|
-
|
|
419
|
+
recordFsRetry(
|
|
420
|
+
logger,
|
|
421
|
+
"rename",
|
|
422
|
+
filePath,
|
|
423
|
+
retryOnTransientFsError(() => {
|
|
424
|
+
renameSync(tempPath, filePath);
|
|
425
|
+
}),
|
|
426
|
+
);
|
|
407
427
|
} catch (error) {
|
|
408
428
|
safeDeleteFile(logger, tempPath, "temporary permission-forwarding");
|
|
409
429
|
throw error;
|
|
410
430
|
}
|
|
411
431
|
}
|
|
412
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Record a filesystem operation that only succeeded after retrying.
|
|
435
|
+
*
|
|
436
|
+
* Debug-only: a recovered write decided nothing, so it does not belong in the
|
|
437
|
+
* permission-decision record — but `attempts` and `code` are the whole
|
|
438
|
+
* diagnosis when a host keeps losing writes, and `debugLog` is exactly the
|
|
439
|
+
* switch a user reaching for that diagnosis turns on.
|
|
440
|
+
*/
|
|
441
|
+
function recordFsRetry(
|
|
442
|
+
logger: DebugReviewLogger | null,
|
|
443
|
+
operation: "rename" | "mkdir",
|
|
444
|
+
path: string,
|
|
445
|
+
record: TransientFsRetryRecord,
|
|
446
|
+
): void {
|
|
447
|
+
if (record.attempts === 1) {
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
logger?.debug("permission_forwarding.fs_retried", {
|
|
451
|
+
operation,
|
|
452
|
+
path,
|
|
453
|
+
attempts: record.attempts,
|
|
454
|
+
code: record.code,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
413
458
|
export function readForwardedPermissionRequest(
|
|
414
459
|
logger: DebugReviewLogger | null,
|
|
415
460
|
filePath: string,
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transient-fs-retry.ts — Survive a file lock that is about to clear.
|
|
3
|
+
*
|
|
4
|
+
* On Windows a `rename` or `mkdir` is not the POSIX operation of the same
|
|
5
|
+
* name: an antivirus scanner, the search indexer, or any other process holding
|
|
6
|
+
* a transient handle on a path fails the call with `EPERM`/`EBUSY`/`EACCES`.
|
|
7
|
+
* The forwarded-permission writes that ride on those calls are then simply
|
|
8
|
+
* lost — a child's request write becomes a refused tool call, and a serving
|
|
9
|
+
* heartbeat goes unpublished (#914).
|
|
10
|
+
*
|
|
11
|
+
* A few attempts a few milliseconds apart is enough for the common case. The
|
|
12
|
+
* decision lives here rather than at either call site so both inherit one
|
|
13
|
+
* errno set and one budget, and so it can be tested without a filesystem.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Tuning seams, injected so a unit test asserts the backoff without sleeping. */
|
|
17
|
+
export interface TransientFsRetryOptions {
|
|
18
|
+
delaysMs?: readonly number[];
|
|
19
|
+
sleep?: (ms: number) => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** How an operation that eventually succeeded got there. */
|
|
23
|
+
export interface TransientFsRetryRecord {
|
|
24
|
+
/** Attempts made, counting the first — `1` when it succeeded outright. */
|
|
25
|
+
attempts: number;
|
|
26
|
+
/** The transient errno that forced the retries, or `null` when there were none. */
|
|
27
|
+
code: string | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run `operation`, retrying while a transient filesystem lock rejects it.
|
|
32
|
+
*
|
|
33
|
+
* Returns how it went, so the caller can record a recovery without this module
|
|
34
|
+
* having to know anything about logging. An error outside the transient set is
|
|
35
|
+
* rethrown immediately with no sleep, keeping a permanent `ENOTDIR`/`ENOENT`
|
|
36
|
+
* exactly as fast as it is today; an exhausted budget rethrows the last error,
|
|
37
|
+
* so every caller's existing `catch` keeps working unchanged.
|
|
38
|
+
*/
|
|
39
|
+
export function retryOnTransientFsError(
|
|
40
|
+
operation: () => void,
|
|
41
|
+
options?: TransientFsRetryOptions,
|
|
42
|
+
): TransientFsRetryRecord {
|
|
43
|
+
const delaysMs = options?.delaysMs ?? DEFAULT_RETRY_DELAYS_MS;
|
|
44
|
+
const sleep = options?.sleep ?? sleepBlocking;
|
|
45
|
+
|
|
46
|
+
let attempts = 0;
|
|
47
|
+
let code: string | null = null;
|
|
48
|
+
|
|
49
|
+
for (;;) {
|
|
50
|
+
try {
|
|
51
|
+
operation();
|
|
52
|
+
return { attempts: attempts + 1, code };
|
|
53
|
+
} catch (error) {
|
|
54
|
+
const transientCode = transientErrorCode(error);
|
|
55
|
+
if (transientCode === null || attempts >= delaysMs.length) {
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
code = transientCode;
|
|
59
|
+
sleep(delaysMs[attempts] ?? 0);
|
|
60
|
+
attempts += 1;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Module-private ─────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Three retries, 60 ms of blocking at worst.
|
|
69
|
+
*
|
|
70
|
+
* Sized against the two windows it has to fit inside: well under one
|
|
71
|
+
* `PERMISSION_FORWARDING_POLL_INTERVAL_MS` tick, so a retrying heartbeat write
|
|
72
|
+
* cannot push the serving node's timer into the next tick, and far under the
|
|
73
|
+
* grace a forwarding child waits out, so a parent retrying its own writes can
|
|
74
|
+
* never be the reason a child gives up on it.
|
|
75
|
+
*/
|
|
76
|
+
const DEFAULT_RETRY_DELAYS_MS: readonly number[] = [10, 20, 30];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The errno values a transient lock produces on an otherwise-valid operation.
|
|
80
|
+
*
|
|
81
|
+
* This set is the platform gate: `process.platform` is unreadable in `src/`,
|
|
82
|
+
* and a POSIX host that produces one of these on a rename is in the same
|
|
83
|
+
* situation a Windows host is — worth one more attempt, and no worse off for
|
|
84
|
+
* it.
|
|
85
|
+
*/
|
|
86
|
+
const TRANSIENT_FS_ERROR_CODES: ReadonlySet<string> = new Set([
|
|
87
|
+
"EPERM",
|
|
88
|
+
"EBUSY",
|
|
89
|
+
"EACCES",
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
/** The transient errno `error` carries, or `null` when retrying cannot help. */
|
|
93
|
+
function transientErrorCode(error: unknown): string | null {
|
|
94
|
+
if (typeof error !== "object" || error === null || !("code" in error)) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const { code } = error as { code?: unknown };
|
|
98
|
+
return typeof code === "string" && TRANSIENT_FS_ERROR_CODES.has(code)
|
|
99
|
+
? code
|
|
100
|
+
: null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Block the calling thread for `ms`.
|
|
105
|
+
*
|
|
106
|
+
* Synchronous because every caller is: the heartbeat publish satisfies a
|
|
107
|
+
* `void` seam a timer drives. `Atomics.wait` on a never-notified buffer is the
|
|
108
|
+
* standard blocking sleep and is permitted on Node's main thread.
|
|
109
|
+
*
|
|
110
|
+
* The buffer is module-scoped — which persists across same-cwd session
|
|
111
|
+
* switches — and that is safe here precisely because nothing ever writes to
|
|
112
|
+
* it: every wait sees the initial zero and times out.
|
|
113
|
+
*/
|
|
114
|
+
const SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
|
|
115
|
+
|
|
116
|
+
function sleepBlocking(ms: number): void {
|
|
117
|
+
Atomics.wait(SLEEP_BUFFER, 0, 0, ms);
|
|
118
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ARG_NODE_TYPES,
|
|
3
|
+
resolveNodeText,
|
|
4
|
+
} from "#src/access-intent/bash/node-text";
|
|
5
|
+
import { getWarmBashParser, type TSNode } from "#src/access-intent/bash/parser";
|
|
6
|
+
import { isPlainRecord } from "#src/value-guards";
|
|
7
|
+
import { isSensitiveName, REDACTED_PLACEHOLDER } from "./log-redaction";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Grammar-anchored masking of a secret bound to a sensitive name *inside* a
|
|
11
|
+
* bash command string.
|
|
12
|
+
*
|
|
13
|
+
* Key-name redaction (`log-redaction.ts`) masks a value because of the key it
|
|
14
|
+
* is bound to, and a command string is one opaque value under the key
|
|
15
|
+
* `command`. This module asks the same question of the names a command binds
|
|
16
|
+
* values to — a shell variable and an HTTP header field — so one predicate
|
|
17
|
+
* answers for all three binding forms.
|
|
18
|
+
*
|
|
19
|
+
* Every rule matches a **parse node**, never a substring of the command text.
|
|
20
|
+
* That is what keeps it usable: measured against a 12 MB review log (7146
|
|
21
|
+
* unique commands), a raw-string scan for a sensitively-named assignment
|
|
22
|
+
* matched ten commands and every one was embedded Python (`key=lambda x: x[1]`)
|
|
23
|
+
* or a `sed` pattern; the same rule anchored to a `variable_assignment` node
|
|
24
|
+
* matched none. See `docs/decisions/0010-permission-log-secret-exposure.md`.
|
|
25
|
+
*
|
|
26
|
+
* A value with no name bound to it — a secret typed as a `grep` pattern — is
|
|
27
|
+
* out of reach of a structural rule and stays unmasked.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** The log keys whose value is a bash command string. */
|
|
31
|
+
export const COMMAND_BEARING_LOG_KEYS: ReadonlySet<string> = new Set([
|
|
32
|
+
"command",
|
|
33
|
+
"executedUnit",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
/** A range of the command to replace, and what to put in its place. */
|
|
37
|
+
interface MaskSpan {
|
|
38
|
+
readonly start: number;
|
|
39
|
+
readonly end: number;
|
|
40
|
+
readonly replacement: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Mask every sensitively-named value in a bash command string.
|
|
45
|
+
*
|
|
46
|
+
* Best-effort by design: a cold parser, a parse that throws, and a parse that
|
|
47
|
+
* recovered from a syntax error all yield whatever the walk did resolve rather
|
|
48
|
+
* than blanking the field, because the command text is the main reason the
|
|
49
|
+
* review log is read. It never throws — the writer sits under the fail-closed
|
|
50
|
+
* `tool_call` boundary, where a raised mask would cost the whole log line.
|
|
51
|
+
*/
|
|
52
|
+
export function redactCommandSecrets(command: string): string {
|
|
53
|
+
if (!command) return command;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const parser = getWarmBashParser();
|
|
57
|
+
if (!parser) return command;
|
|
58
|
+
const tree = parser.parse(command);
|
|
59
|
+
if (!tree) return command;
|
|
60
|
+
try {
|
|
61
|
+
const spans: MaskSpan[] = [];
|
|
62
|
+
collectMaskSpans(tree.rootNode, spans);
|
|
63
|
+
return applyMaskSpans(command, spans);
|
|
64
|
+
} finally {
|
|
65
|
+
tree.delete();
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
return command;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Apply {@link redactCommandSecrets} to every command-bearing key in a log
|
|
74
|
+
* record.
|
|
75
|
+
*
|
|
76
|
+
* Recurses through plain objects and arrays, like the width cap beside it: all
|
|
77
|
+
* of today's producers write `command` and `executedUnit` at the top level, but
|
|
78
|
+
* a writer stage that only looks at the top level is one a later nested
|
|
79
|
+
* producer escapes without anyone noticing.
|
|
80
|
+
*/
|
|
81
|
+
export function maskCommandFields<T>(details: T): T {
|
|
82
|
+
return maskValue(details, false) as T;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function maskValue(value: unknown, bindsCommand: boolean): unknown {
|
|
86
|
+
if (typeof value === "string") {
|
|
87
|
+
return bindsCommand ? redactCommandSecrets(value) : value;
|
|
88
|
+
}
|
|
89
|
+
if (Array.isArray(value)) {
|
|
90
|
+
return value.map((entry) => maskValue(entry, bindsCommand));
|
|
91
|
+
}
|
|
92
|
+
if (isPlainRecord(value)) {
|
|
93
|
+
return Object.fromEntries(
|
|
94
|
+
Object.entries(value).map(([key, entry]) => [
|
|
95
|
+
key,
|
|
96
|
+
maskValue(entry, COMMAND_BEARING_LOG_KEYS.has(key)),
|
|
97
|
+
]),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function collectMaskSpans(node: TSNode, spans: MaskSpan[]): void {
|
|
104
|
+
const span = maskSpanOf(node);
|
|
105
|
+
if (span) spans.push(span);
|
|
106
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
107
|
+
const child = node.child(i);
|
|
108
|
+
if (child) collectMaskSpans(child, spans);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function maskSpanOf(node: TSNode): MaskSpan | null {
|
|
113
|
+
return (
|
|
114
|
+
assignmentValueSpan(node) ??
|
|
115
|
+
wordAssignmentSpan(node) ??
|
|
116
|
+
headerValueSpan(node)
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* `KEY="sk-…" curl …`, `KEY=sk-…`, `export OPENROUTER_KEY="sk-…"`.
|
|
122
|
+
*
|
|
123
|
+
* The span runs to the assignment node's own end rather than the value node's,
|
|
124
|
+
* so a value the grammar splits across several children is covered whole.
|
|
125
|
+
*/
|
|
126
|
+
function assignmentValueSpan(node: TSNode): MaskSpan | null {
|
|
127
|
+
if (node.type !== "variable_assignment") return null;
|
|
128
|
+
const name = node.child(0);
|
|
129
|
+
if (!name || !isSensitiveName(name.text)) return null;
|
|
130
|
+
const value = node.child(2);
|
|
131
|
+
if (!value) return null;
|
|
132
|
+
return maskSpan(value.startIndex, node.endIndex, REDACTED_PLACEHOLDER);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* `env MY_KEY=abc deploy`, which tree-sitter classifies as a plain `word`
|
|
137
|
+
* rather than an assignment because it follows a command name.
|
|
138
|
+
*
|
|
139
|
+
* The name must open with a letter or underscore, so a long option
|
|
140
|
+
* (`--my-key=abc`) cannot match: an option binds its value to a flag, and the
|
|
141
|
+
* flag forms are deliberately out of scope.
|
|
142
|
+
*/
|
|
143
|
+
const WORD_ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=/;
|
|
144
|
+
|
|
145
|
+
function wordAssignmentSpan(node: TSNode): MaskSpan | null {
|
|
146
|
+
if (node.type !== "word") return null;
|
|
147
|
+
const match = WORD_ASSIGNMENT.exec(node.text);
|
|
148
|
+
if (!match || !isSensitiveName(match[1])) return null;
|
|
149
|
+
return maskSpan(
|
|
150
|
+
node.startIndex + match[0].length,
|
|
151
|
+
node.endIndex,
|
|
152
|
+
REDACTED_PLACEHOLDER,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** `curl -H "Authorization: Bearer sk-…"`, in any of its quoting forms. */
|
|
157
|
+
const HEADER_FIELD = /^([A-Za-z][A-Za-z0-9_-]*)[ \t]*:[ \t]*\S/;
|
|
158
|
+
|
|
159
|
+
function headerValueSpan(node: TSNode): MaskSpan | null {
|
|
160
|
+
if (!ARG_NODE_TYPES.has(node.type)) return null;
|
|
161
|
+
const match = HEADER_FIELD.exec(resolveNodeText(node));
|
|
162
|
+
const field = match?.[1];
|
|
163
|
+
if (!field || !isSensitiveName(field) || isCamelCased(field)) return null;
|
|
164
|
+
const colon = node.text.indexOf(":");
|
|
165
|
+
if (colon < 0) return null;
|
|
166
|
+
// The span swallows a closing quote, so the replacement puts one back and
|
|
167
|
+
// the masked argument stays quoted the way it was written.
|
|
168
|
+
return maskSpan(
|
|
169
|
+
node.startIndex + colon + 1,
|
|
170
|
+
node.endIndex,
|
|
171
|
+
REDACTED_PLACEHOLDER + openQuoteAt(node.text, colon),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* An HTTP field name is hyphenated (`X-Api-Key`), never camel-cased.
|
|
177
|
+
*
|
|
178
|
+
* Without this the only false positives in the measured corpus were two
|
|
179
|
+
* records of `grep "legalDirectionalKeys: readonly"` — a search pattern over
|
|
180
|
+
* TypeScript source, which names a field of nothing.
|
|
181
|
+
*/
|
|
182
|
+
function isCamelCased(field: string): boolean {
|
|
183
|
+
return /[a-z][A-Z]/.test(field);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The quote character still open at `index`, or the empty string.
|
|
188
|
+
*
|
|
189
|
+
* Read at the mask's own position rather than off the argument's first
|
|
190
|
+
* character: a field name can straddle a quote boundary (`Auth"orization: "$T`),
|
|
191
|
+
* and the quote the mask swallowed is the one open where it begins.
|
|
192
|
+
*/
|
|
193
|
+
function openQuoteAt(text: string, index: number): string {
|
|
194
|
+
let quote = "";
|
|
195
|
+
for (let i = 0; i < index; i++) {
|
|
196
|
+
const char = text[i];
|
|
197
|
+
if (quote === "") {
|
|
198
|
+
if (char === '"' || char === "'") quote = char;
|
|
199
|
+
} else if (quote === '"' && char === "\\") {
|
|
200
|
+
i += 1;
|
|
201
|
+
} else if (char === quote) {
|
|
202
|
+
quote = "";
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return quote;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function maskSpan(
|
|
209
|
+
start: number,
|
|
210
|
+
end: number,
|
|
211
|
+
replacement: string,
|
|
212
|
+
): MaskSpan | null {
|
|
213
|
+
return start < end ? { start, end, replacement } : null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Replace each span, outermost-wins and right to left.
|
|
218
|
+
*
|
|
219
|
+
* A sensitive assignment whose value is itself a header argument yields two
|
|
220
|
+
* spans, one inside the other; masking both would nest a placeholder inside a
|
|
221
|
+
* region already replaced. Working right to left keeps the earlier offsets
|
|
222
|
+
* valid as the string shortens.
|
|
223
|
+
*/
|
|
224
|
+
function applyMaskSpans(command: string, spans: MaskSpan[]): string {
|
|
225
|
+
if (spans.length === 0) return command;
|
|
226
|
+
|
|
227
|
+
const ordered = [...spans].sort((a, b) => a.start - b.start || b.end - a.end);
|
|
228
|
+
const disjoint: MaskSpan[] = [];
|
|
229
|
+
for (const span of ordered) {
|
|
230
|
+
const previous = disjoint.at(-1);
|
|
231
|
+
if (previous && span.start < previous.end) continue;
|
|
232
|
+
disjoint.push(span);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let masked = command;
|
|
236
|
+
for (const span of disjoint.toReversed()) {
|
|
237
|
+
masked =
|
|
238
|
+
masked.slice(0, span.start) + span.replacement + masked.slice(span.end);
|
|
239
|
+
}
|
|
240
|
+
return masked;
|
|
241
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { isPlainRecord } from "#src/value-guards";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* The permission review log's width bound (ADR 0011 §6).
|
|
3
5
|
*
|
|
@@ -57,7 +59,7 @@ function capValue(value: unknown, maxWidth: number): unknown {
|
|
|
57
59
|
if (Array.isArray(value)) {
|
|
58
60
|
return value.map((entry) => capValue(entry, maxWidth));
|
|
59
61
|
}
|
|
60
|
-
if (
|
|
62
|
+
if (isPlainRecord(value)) {
|
|
61
63
|
return Object.fromEntries(
|
|
62
64
|
Object.entries(value).map(([key, entry]) => [
|
|
63
65
|
key,
|
|
@@ -67,16 +69,3 @@ function capValue(value: unknown, maxWidth: number): unknown {
|
|
|
67
69
|
}
|
|
68
70
|
return value;
|
|
69
71
|
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Whether a value is a record this cap should descend into.
|
|
73
|
-
*
|
|
74
|
-
* A class instance (a `Date`, an `Error`) is left alone: rebuilding it as a
|
|
75
|
-
* plain object would change what the writer serializes, and the cap's job is
|
|
76
|
-
* to shorten strings, not to reshape a value.
|
|
77
|
-
*/
|
|
78
|
-
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
79
|
-
if (typeof value !== "object" || value === null) return false;
|
|
80
|
-
const prototype: unknown = Object.getPrototypeOf(value);
|
|
81
|
-
return prototype === Object.prototype || prototype === null;
|
|
82
|
-
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createJsonSafeReplacer } from "./json-safe-stringify";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Name-based redaction for the permission logs.
|
|
5
5
|
*
|
|
6
6
|
* The technique is deliberately structural rather than predictive: a value is
|
|
7
7
|
* masked because of the *name* it is bound to, never because of what it looks
|
|
@@ -9,19 +9,41 @@ import { createJsonSafeReplacer } from "./json-safe-stringify";
|
|
|
9
9
|
* was measured against a real 6.7 MB review log and declined — see
|
|
10
10
|
* `docs/decisions/0010-permission-log-secret-exposure.md`.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* This module owns the predicate and the log-key binding form.
|
|
13
|
+
* `command-redaction.ts` asks the same predicate about the names a bash
|
|
14
|
+
* command binds values to.
|
|
15
|
+
*
|
|
16
|
+
* The boundary that follows, stated once: a value bound to a sensitive name is
|
|
17
|
+
* masked — whether the name is a log key, a shell variable, or a request
|
|
18
|
+
* header field. A secret with no name bound to it, such as one typed as a
|
|
19
|
+
* `grep` pattern, is not.
|
|
15
20
|
*/
|
|
16
21
|
|
|
17
22
|
export const REDACTED_PLACEHOLDER = "[redacted]";
|
|
18
23
|
|
|
19
|
-
|
|
20
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Names that bind a credential, matched case-insensitively.
|
|
26
|
+
*
|
|
27
|
+
* `api[-_]?keys?` and `private[-_]?keys?` are kept alongside the general
|
|
28
|
+
* name-boundary `key` rule rather than subsumed by it, so the whole predicate
|
|
29
|
+
* is a union with the pattern it replaced and can add a name but never drop
|
|
30
|
+
* one — the separator-less `apikey` matches only via the specific alternative.
|
|
31
|
+
*/
|
|
32
|
+
const SENSITIVE_NAME_PATTERN =
|
|
33
|
+
/authorization|api[-_]?keys?|private[-_]?keys?|secret|token|password|passwd|credential|cookie|(?:^|[-_])keys?(?:$|[-_])/i;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A `key` bound as the tail of a camel-cased name (`apiKey`, `sortKeys`).
|
|
37
|
+
*
|
|
38
|
+
* Deliberately case-sensitive and separate from the pattern above: under `/i`
|
|
39
|
+
* the leading `[a-z0-9]` would match an uppercase letter and `Key` would match
|
|
40
|
+
* `key`, so `monkey` would read as sensitive.
|
|
41
|
+
*/
|
|
42
|
+
const CAMEL_KEY_PATTERN = /[a-z0-9](?:Key|Keys)(?:$|[A-Z_-])/;
|
|
21
43
|
|
|
22
|
-
/** True when a
|
|
23
|
-
export function
|
|
24
|
-
return
|
|
44
|
+
/** True when a name binds a credential-bearing value. */
|
|
45
|
+
export function isSensitiveName(name: string): boolean {
|
|
46
|
+
return SENSITIVE_NAME_PATTERN.test(name) || CAMEL_KEY_PATTERN.test(name);
|
|
25
47
|
}
|
|
26
48
|
|
|
27
49
|
/**
|
|
@@ -36,7 +58,7 @@ export function redactedJsonStringify(value: unknown): string | undefined {
|
|
|
36
58
|
return JSON.stringify(
|
|
37
59
|
value,
|
|
38
60
|
createJsonSafeReplacer((key, currentValue) =>
|
|
39
|
-
currentValue != null &&
|
|
61
|
+
currentValue != null && isSensitiveName(key)
|
|
40
62
|
? REDACTED_PLACEHOLDER
|
|
41
63
|
: currentValue,
|
|
42
64
|
),
|
package/src/logging/logging.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
EXTENSION_ID,
|
|
5
5
|
type PermissionSystemExtensionConfig,
|
|
6
6
|
} from "#src/config/extension-config";
|
|
7
|
+
import { maskCommandFields } from "./command-redaction";
|
|
7
8
|
import { capLogFieldWidths, resolveReviewLogFieldWidth } from "./log-field-cap";
|
|
8
9
|
import {
|
|
9
10
|
OWNER_ONLY_FILE_MODE,
|
|
@@ -39,13 +40,41 @@ export function createPermissionSystemLogger(
|
|
|
39
40
|
const hardened = new Set<string>();
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
|
-
* The
|
|
43
|
+
* The transform stages every log line passes through, in the order they must
|
|
44
|
+
* run.
|
|
45
|
+
*
|
|
46
|
+
* Command masking runs first, and it runs for both streams. Capping a command
|
|
47
|
+
* before masking it would hand the masker a truncated command — a parse of
|
|
48
|
+
* something the agent never ran — and the debug stream carries the same
|
|
49
|
+
* payload as the review stream, so a mask that skipped it would only move the
|
|
50
|
+
* exposure rather than close it.
|
|
43
51
|
*
|
|
44
52
|
* `maxFieldWidth` bounds every string the line carries; it is supplied for
|
|
45
53
|
* the review stream and withheld for the debug stream, which is opt-in and
|
|
46
|
-
* exists to be read in full. Capping happens before redaction, which
|
|
47
|
-
* by
|
|
54
|
+
* exists to be read in full. Capping happens before key-name redaction, which
|
|
55
|
+
* masks by name and so still masks a sensitive value whole.
|
|
48
56
|
*/
|
|
57
|
+
const prepareLogLine = (
|
|
58
|
+
stream: "debug" | "review",
|
|
59
|
+
event: string,
|
|
60
|
+
details: Record<string, unknown>,
|
|
61
|
+
maxFieldWidth?: number,
|
|
62
|
+
): string | undefined => {
|
|
63
|
+
const masked = maskCommandFields(details);
|
|
64
|
+
const bounded =
|
|
65
|
+
maxFieldWidth === undefined
|
|
66
|
+
? masked
|
|
67
|
+
: capLogFieldWidths(masked, maxFieldWidth);
|
|
68
|
+
return redactedJsonStringify({
|
|
69
|
+
timestamp: new Date().toISOString(),
|
|
70
|
+
extension: EXTENSION_ID,
|
|
71
|
+
stream,
|
|
72
|
+
event,
|
|
73
|
+
...bounded,
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** The only place a log line is produced. */
|
|
49
78
|
const writeLine = (
|
|
50
79
|
stream: "debug" | "review",
|
|
51
80
|
path: string,
|
|
@@ -59,17 +88,7 @@ export function createPermissionSystemLogger(
|
|
|
59
88
|
}
|
|
60
89
|
|
|
61
90
|
try {
|
|
62
|
-
const
|
|
63
|
-
maxFieldWidth === undefined
|
|
64
|
-
? details
|
|
65
|
-
: capLogFieldWidths(details, maxFieldWidth);
|
|
66
|
-
const line = redactedJsonStringify({
|
|
67
|
-
timestamp: new Date().toISOString(),
|
|
68
|
-
extension: EXTENSION_ID,
|
|
69
|
-
stream,
|
|
70
|
-
event,
|
|
71
|
-
...bounded,
|
|
72
|
-
});
|
|
91
|
+
const line = prepareLogLine(stream, event, details, maxFieldWidth);
|
|
73
92
|
if (!line) {
|
|
74
93
|
return `Failed to write permission-system ${stream} log '${path}': event could not be serialized.`;
|
|
75
94
|
}
|
package/src/value-guards.ts
CHANGED
|
@@ -6,6 +6,22 @@ export function toRecord(value: unknown): Record<string, unknown> {
|
|
|
6
6
|
return value as Record<string, unknown>;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Whether a value is a record a structural walk should descend into.
|
|
11
|
+
*
|
|
12
|
+
* A class instance (a `Date`, an `Error`) is not: rebuilding one as a plain
|
|
13
|
+
* object would change what a serializer downstream writes. Distinct from
|
|
14
|
+
* {@link toRecord}, which reads a value as a record without asking how it was
|
|
15
|
+
* built.
|
|
16
|
+
*/
|
|
17
|
+
export function isPlainRecord(
|
|
18
|
+
value: unknown,
|
|
19
|
+
): value is Record<string, unknown> {
|
|
20
|
+
if (typeof value !== "object" || value === null) return false;
|
|
21
|
+
const prototype: unknown = Object.getPrototypeOf(value);
|
|
22
|
+
return prototype === Object.prototype || prototype === null;
|
|
23
|
+
}
|
|
24
|
+
|
|
9
25
|
export function getNonEmptyString(value: unknown): string | null {
|
|
10
26
|
if (typeof value !== "string") {
|
|
11
27
|
return null;
|