@gotgenes/pi-permission-system 32.0.2 → 32.0.4
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 +26 -0
- package/README.md +3 -2
- package/docs/configuration.md +19 -6
- package/docs/troubleshooting.md +3 -2
- package/package.json +1 -1
- package/src/access-intent/bash/bash-path-resolver.ts +19 -1
- package/src/access-intent/bash/command-enumeration.ts +68 -7
- package/src/access-intent/bash/parser.ts +20 -2
- package/src/access-intent/bash/program.ts +21 -11
- package/src/access-intent/bash/sync-commands.ts +10 -2
- package/src/access-intent/bash/unresolved-salvage.ts +97 -0
- package/src/handlers/gates/bash-command.ts +34 -20
- 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,32 @@ 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.4](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v32.0.3...pi-permission-system-v32.0.4) (2026-09-16)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** evaluate bash rules against a command a partial parse dropped ([28cedc4](https://github.com/gotgenes/pi-packages/commit/28cedc45dd4a11ee9189f41ee661a051d2b13936)), closes [#875](https://github.com/gotgenes/pi-packages/issues/875)
|
|
14
|
+
* **pi-permission-system:** gate the paths a command dropped by a partial parse reads ([6f1d032](https://github.com/gotgenes/pi-packages/commit/6f1d0328f8151da8716641f0ee0c25341699cd75)), closes [#875](https://github.com/gotgenes/pi-packages/issues/875)
|
|
15
|
+
* **pi-permission-system:** keep the whole-command deny reachable when a salvage recovers the only units ([ab91a66](https://github.com/gotgenes/pi-packages/commit/ab91a660eafb879a3370104cb6f68353041a7cbb)), closes [#875](https://github.com/gotgenes/pi-packages/issues/875)
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* **pi-permission-system:** record the salvage of a bash command a partial parse dropped ([17a3684](https://github.com/gotgenes/pi-packages/commit/17a3684f182467c19e1ca56664e1adf900980f11))
|
|
20
|
+
|
|
21
|
+
## [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)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Bug Fixes
|
|
25
|
+
|
|
26
|
+
* **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)
|
|
27
|
+
* **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)
|
|
28
|
+
|
|
29
|
+
### Documentation
|
|
30
|
+
|
|
31
|
+
* **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)
|
|
32
|
+
* **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)
|
|
33
|
+
|
|
8
34
|
## [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)
|
|
9
35
|
|
|
10
36
|
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ Permission enforcement extension for the [Pi](https://pi.mariozechner.at/) codin
|
|
|
19
19
|
- **Gates MCP and skill access** at server, tool, and skill-name granularity
|
|
20
20
|
- **Protects sensitive file patterns** — cross-cutting `path` rules deny `.env`, `~/.ssh/*`, etc. across all tools and bash at once, matching both the path as referenced and its symlink-resolved form so a deny cannot be evaded through a symlink alias
|
|
21
21
|
- **Guards external paths** — prompts before file tools or bash commands reach outside `cwd`
|
|
22
|
-
- **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry and a matching `permissions:decision` broadcast), and a bash command the parser could not resolve, in whole or in part — or an indirection wrapper that hides the gated command (`bash -c`/`eval`, `sudo`, `env`, `xargs`, `find -exec`, …) — prompts (`ask`) rather than passing silently, unless the wrapped command is a pure reader whose direction is provable whatever it is fed (`xargs grep -l foo`)
|
|
22
|
+
- **Fails closed** — an internal gate error blocks the tool (with a `gate_error` review-log entry and a matching `permissions:decision` broadcast), and a bash command the parser could not resolve, in whole or in part — or an indirection wrapper that hides the gated command (`bash -c`/`eval`, `sudo`, `env`, `xargs`, `find -exec`, …) — prompts (`ask`) rather than passing silently, unless the wrapped command is a pure reader whose direction is provable whatever it is fed (`xargs grep -l foo`); where a partial parse failure's own region re-parses cleanly on its own, the commands and paths it holds are recovered and gated rather than merely prompted for
|
|
23
23
|
- **Forwards prompts from subagents** — `ask` policies work even in non-UI execution contexts
|
|
24
24
|
- **Broadcasts UI prompt events** — `permissions:ui_prompt` fires only when the permission system is about to invoke the active user-facing permission UI, and every prompt it announces — including one forwarded up from a subagent — is answered by a `permissions:decision` on the same bus
|
|
25
25
|
- **Native [`@gotgenes/pi-subagents`](https://github.com/gotgenes/pi-subagents) integration** — in-process child sessions register with the permission system automatically, enabling per-agent policy enforcement and `ask`-state forwarding to the parent UI without configuration
|
|
@@ -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. |
|
|
@@ -429,11 +429,13 @@ The bash gate fails closed: when in doubt it blocks or prompts, never silently a
|
|
|
429
429
|
- If the permission gate throws an internal error (for example a transient tree-sitter parser-init failure), the tool call is **blocked** rather than passed ungated, and a `gate_error` entry is written to the review log naming the failure.
|
|
430
430
|
- A non-empty command that cannot be parsed into command units resolves to **`ask`** (the synthetic `<unparseable-bash-command>` pattern in the review log) instead of falling through to a permissive top-level `*`.
|
|
431
431
|
A `deny` rule covering the whole command still denies outright — the synthetic `ask` never masks a hard deny into an approvable prompt.
|
|
432
|
+
That whole-command check runs whenever the parse itself matched nothing, including when the recovery below went on to recover a command from the wreckage, so a rule naming the command in context (`"* rm -rf *"`) is still consulted.
|
|
432
433
|
An empty, whitespace-only, or comment-only command has nothing to gate and is resolved normally.
|
|
433
434
|
- A command the parser could only *partly* resolve is floored the same way (the synthetic `<unparsed-bash-subtree>` pattern in the review log).
|
|
434
435
|
Recovered structure is not evidence of what runs, so any command unit at or beneath the statement holding the unresolved region has its `allow` clamped up to `ask`; an explicit `deny` or `ask` on that unit still decides.
|
|
435
|
-
The prompt names the **whole** command rather than the unit, because
|
|
436
|
+
The prompt names the **whole** command rather than the unit, because the fragment that did parse is not what you need to see.
|
|
436
437
|
A statement beside the failed one keeps its own rule.
|
|
438
|
+
Where the unresolved region's own text parses cleanly on its own, the commands and paths inside it are recovered and gated too, so a `deny` covering one of them still denies rather than prompting — a region whose own text does not re-parse is left to the floor, since error recovery invents the structure inside one and inventions do not re-parse.
|
|
437
439
|
Most such commands are simply malformed, and the shell would refuse them too — but not all: `git commit -F - <<'MSG' 2>&1 | tail -4` is valid bash that `tree-sitter-bash` cannot parse, because a heredoc redirect combined with `2>&1` **and** a pipe defeats the grammar though each pairing alone is fine.
|
|
438
440
|
- An opaque-payload wrapper — `bash`/`sh`/`dash`/`zsh`/`ksh` invoked with `-c`, or `eval` — carries its inner program in a quoted argument that is not re-parsed, so its decision is floored to at least **`ask`** (the synthetic `<opaque-bash-wrapper>` pattern in the review log).
|
|
439
441
|
An `allow` (including a permissive top-level `*`) is clamped up to `ask`, while an explicit `deny` rule on the wrapper still denies.
|
|
@@ -1197,19 +1199,30 @@ Both logs are created **owner-only** (`0600`, in a `0700` directory), and a log
|
|
|
1197
1199
|
The permission-forwarding request and response files are written the same way.
|
|
1198
1200
|
This closes the shared-host case: another user on the same machine cannot read them.
|
|
1199
1201
|
|
|
1200
|
-
Values bound to a **sensitive
|
|
1202
|
+
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
1203
|
So a tool called with `{"authorization": "Bearer …"}` records `{"authorization": "[redacted]"}`.
|
|
1202
1204
|
|
|
1205
|
+
A bash command binds values to names too, and the same predicate answers for those.
|
|
1206
|
+
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:
|
|
1207
|
+
|
|
1208
|
+
```text
|
|
1209
|
+
KEY="sk-or-v1-…" curl https://x → KEY=[redacted] curl https://x
|
|
1210
|
+
env MY_KEY=… deploy → env MY_KEY=[redacted] deploy
|
|
1211
|
+
curl -H "Authorization: Bearer sk-…" → curl -H "Authorization:[redacted]"
|
|
1212
|
+
```
|
|
1213
|
+
|
|
1203
1214
|
The boundary is worth stating exactly, because it is easy to over-read:
|
|
1204
1215
|
|
|
1205
|
-
> A value bound to a sensitive
|
|
1216
|
+
> A value bound to a sensitive name is masked — whether the name is a log key, a shell variable, or a request header field.
|
|
1217
|
+
> A secret with no name bound to it, such as one typed as a `grep` pattern, is not.
|
|
1206
1218
|
|
|
1207
|
-
|
|
1219
|
+
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
1220
|
The extension deliberately does not try to guess which parts of a command look secret-shaped — see [ADR 0010] for the measured reasoning.
|
|
1221
|
+
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
1222
|
|
|
1210
1223
|
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
1224
|
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
|
|
1225
|
+
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
1226
|
The debug log is left unbounded, since it is opt-in and exists to be read in full.
|
|
1214
1227
|
|
|
1215
1228
|
Practical guidance:
|
package/docs/troubleshooting.md
CHANGED
|
@@ -54,8 +54,9 @@ This makes it easy to verify which files the extension actually loaded:
|
|
|
54
54
|
- This is a permission decision layer, not a sandbox — for true isolation see [Agent Sandboxes](https://engine.build/lab/agent-sandboxes).
|
|
55
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.
|
|
56
56
|
[ADR 0013] §8 records that division and the seam that exports this package's scope decisions to a sandbox launcher.
|
|
57
|
-
- The review log records bash command strings
|
|
58
|
-
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.
|
|
59
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.
|
|
60
61
|
See [Log file sensitivity](configuration.md#log-file-sensitivity) and [ADR 0010].
|
|
61
62
|
|
package/package.json
CHANGED
|
@@ -127,13 +127,31 @@ export class BashPathResolver {
|
|
|
127
127
|
* itself is added to the external paths when it resolves outside the cwd.
|
|
128
128
|
* Containment is always measured against the session cwd baked into the
|
|
129
129
|
* normalizer, so a `workdir` outside the cwd does not widen the sandbox.
|
|
130
|
+
*
|
|
131
|
+
* `salvagedRoots` are regions the primary parse could not resolve, re-parsed
|
|
132
|
+
* cleanly on their own (`unresolved-salvage.ts`, #875). Their candidates are
|
|
133
|
+
* collected into the same array before projection runs, so a path both a
|
|
134
|
+
* salvaged region and the primary parse name folds to one entry rather than
|
|
135
|
+
* showing twice in the prompt. Each is walked under the **unknown** base: a
|
|
136
|
+
* fragment carries no record of the `cd` in force where it sat, and
|
|
137
|
+
* resolving `cat rel.txt` against the session cwd after `cd /outside` would
|
|
138
|
+
* name a different file than the one that runs — a rule for that other path
|
|
139
|
+
* could then allow this access. #393's unknown base declines the claim
|
|
140
|
+
* instead, keeping an absolute token literal-only and unconditionally
|
|
141
|
+
* external while a relative one is not projected at all.
|
|
130
142
|
*/
|
|
131
|
-
resolve(
|
|
143
|
+
resolve(
|
|
144
|
+
rootNode: TSNode,
|
|
145
|
+
salvagedRoots: readonly TSNode[] = [],
|
|
146
|
+
): ResolvedBashPaths {
|
|
132
147
|
const initialBase =
|
|
133
148
|
this.workdir === undefined
|
|
134
149
|
? CWD_BASE
|
|
135
150
|
: this.deriveBaseFromCdTarget(CWD_BASE, this.workdir);
|
|
136
151
|
const candidates = this.collectPathCandidates(rootNode, initialBase);
|
|
152
|
+
for (const salvaged of salvagedRoots) {
|
|
153
|
+
this.walkForCandidates(salvaged, UNKNOWN_BASE, candidates);
|
|
154
|
+
}
|
|
137
155
|
return {
|
|
138
156
|
externalAccesses: this.withWorkdirExternal(
|
|
139
157
|
this.projectExternalPaths(candidates),
|
|
@@ -57,6 +57,19 @@ export interface BashCommand {
|
|
|
57
57
|
* 0013 §10's fail-closed base case (#840).
|
|
58
58
|
*/
|
|
59
59
|
readonly parseUnresolved?: true;
|
|
60
|
+
/**
|
|
61
|
+
* Set when this unit came from a region re-parsed out of a subtree the
|
|
62
|
+
* primary parse could not resolve, rather than from the primary parse
|
|
63
|
+
* itself (#875).
|
|
64
|
+
*
|
|
65
|
+
* Narrower than {@link parseUnresolved}, which a primary unit also carries
|
|
66
|
+
* when its enclosing statement failed. The verdict fold needs the
|
|
67
|
+
* distinction to tell whether the *primary* parse found anything: when it
|
|
68
|
+
* found nothing, the whole command string is the only surface an explicit
|
|
69
|
+
* `deny` can reach (#452), and salvaging a unit must not make that check
|
|
70
|
+
* unreachable.
|
|
71
|
+
*/
|
|
72
|
+
readonly salvaged?: true;
|
|
60
73
|
}
|
|
61
74
|
|
|
62
75
|
/**
|
|
@@ -83,12 +96,34 @@ interface UnitScope {
|
|
|
83
96
|
* resolve, so every unit beneath it is floored rather than trusted (#840).
|
|
84
97
|
*/
|
|
85
98
|
readonly parseUnresolved: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* True when the walk started from a salvaged region rather than the primary
|
|
101
|
+
* parse tree (#875). Relayed unchanged, including into nested executions:
|
|
102
|
+
* everything found inside a salvaged region is salvaged.
|
|
103
|
+
*/
|
|
104
|
+
readonly salvaged: boolean;
|
|
86
105
|
}
|
|
87
106
|
|
|
88
107
|
/** A top-level command in the current shell, writing no file, fully parsed. */
|
|
89
108
|
const TOP_LEVEL_SCOPE: UnitScope = {
|
|
90
109
|
writesViaRedirect: false,
|
|
91
110
|
parseUnresolved: false,
|
|
111
|
+
salvaged: false,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The scope a salvaged region's own units run under.
|
|
116
|
+
*
|
|
117
|
+
* Marked unresolved because the region reached the salvage only by failing in
|
|
118
|
+
* the primary parse, so the verdict fold floors what it recovers rather than
|
|
119
|
+
* trusting it. `writesViaRedirect` starts false for the same reason
|
|
120
|
+
* {@link collectHostedCommands} resets it: a redirect established outside the
|
|
121
|
+
* region is the enclosing statement's, not the region's.
|
|
122
|
+
*/
|
|
123
|
+
const SALVAGED_SCOPE: UnitScope = {
|
|
124
|
+
writesViaRedirect: false,
|
|
125
|
+
parseUnresolved: true,
|
|
126
|
+
salvaged: true,
|
|
92
127
|
};
|
|
93
128
|
|
|
94
129
|
// ── Node-type vocabulary ─────────────────────────────────────────────────────
|
|
@@ -212,6 +247,22 @@ export function collectCommands(node: TSNode): BashCommand[] {
|
|
|
212
247
|
return out;
|
|
213
248
|
}
|
|
214
249
|
|
|
250
|
+
/**
|
|
251
|
+
* Enumerate the command units of a region the primary parse could not resolve,
|
|
252
|
+
* re-parsed cleanly on its own (`unresolved-salvage.ts`, #875).
|
|
253
|
+
*
|
|
254
|
+
* The same walk as {@link collectCommands}, differing only in the scope it
|
|
255
|
+
* starts from: every unit is marked {@link BashCommand.parseUnresolved}, so a
|
|
256
|
+
* command the primary parse dropped is matched against the bash rules — an
|
|
257
|
+
* explicit `deny` fires — while its `allow` is still floored to `ask` by the
|
|
258
|
+
* verdict fold (#840).
|
|
259
|
+
*/
|
|
260
|
+
export function collectSalvagedCommands(node: TSNode): BashCommand[] {
|
|
261
|
+
const out: BashCommand[] = [];
|
|
262
|
+
collectCommandsInto(node, SALVAGED_SCOPE, out);
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
|
|
215
266
|
function collectCommandsInto(
|
|
216
267
|
node: TSNode,
|
|
217
268
|
inherited: UnitScope,
|
|
@@ -228,7 +279,7 @@ function collectCommandsInto(
|
|
|
228
279
|
out.push(makeCommandUnit(node, scope));
|
|
229
280
|
// A command's text already contains any substitution; descend its subtree
|
|
230
281
|
// to ALSO emit the inner commands of command/process substitutions.
|
|
231
|
-
collectHostedCommands(node, out);
|
|
282
|
+
collectHostedCommands(node, scope, out);
|
|
232
283
|
return;
|
|
233
284
|
}
|
|
234
285
|
|
|
@@ -240,7 +291,7 @@ function collectCommandsInto(
|
|
|
240
291
|
if (EXECUTION_HOST_TYPES.has(node.type)) {
|
|
241
292
|
// Not a command itself, but its subtree can host one that really runs
|
|
242
293
|
// (`> $(rm x)`, `< <(rm c)`). Emit only what it hosts (#741).
|
|
243
|
-
collectHostedCommands(node, out);
|
|
294
|
+
collectHostedCommands(node, scope, out);
|
|
244
295
|
return;
|
|
245
296
|
}
|
|
246
297
|
|
|
@@ -281,7 +332,7 @@ function collectCommandsInto(
|
|
|
281
332
|
// really run (`local x=$(rm y)`, `[[ $(rm x) ]]`), so those are enumerated
|
|
282
333
|
// in addition to the statement (#742).
|
|
283
334
|
out.push(makeUnit(node.text, scope));
|
|
284
|
-
collectHostedCommands(node, out);
|
|
335
|
+
collectHostedCommands(node, scope, out);
|
|
285
336
|
}
|
|
286
337
|
|
|
287
338
|
/**
|
|
@@ -328,9 +379,10 @@ function makeUnit(
|
|
|
328
379
|
executedUnit === undefined ? flagged : { ...flagged, executedUnit };
|
|
329
380
|
const exempted =
|
|
330
381
|
floorExemption === undefined ? named : { ...named, floorExemption };
|
|
331
|
-
|
|
382
|
+
const marked: BashCommand = scope.parseUnresolved
|
|
332
383
|
? { ...exempted, parseUnresolved: true }
|
|
333
384
|
: exempted;
|
|
385
|
+
return scope.salvaged ? { ...marked, salvaged: true } : marked;
|
|
334
386
|
}
|
|
335
387
|
|
|
336
388
|
/**
|
|
@@ -450,7 +502,7 @@ function descendStatementChildren(
|
|
|
450
502
|
const child = node.child(i);
|
|
451
503
|
if (!child?.isNamed) continue;
|
|
452
504
|
if (STATEMENT_TYPES.has(child.type)) collectCommandsInto(child, scope, out);
|
|
453
|
-
else collectHostedCommands(child, out);
|
|
505
|
+
else collectHostedCommands(child, scope, out);
|
|
454
506
|
}
|
|
455
507
|
}
|
|
456
508
|
|
|
@@ -465,7 +517,11 @@ function descendStatementChildren(
|
|
|
465
517
|
* `node` may be a context outright or merely host one, so the traversal is the
|
|
466
518
|
* root-inclusive `forEachExecutionIn`.
|
|
467
519
|
*/
|
|
468
|
-
function collectHostedCommands(
|
|
520
|
+
function collectHostedCommands(
|
|
521
|
+
node: TSNode,
|
|
522
|
+
scope: UnitScope,
|
|
523
|
+
out: BashCommand[],
|
|
524
|
+
): void {
|
|
469
525
|
forEachExecutionIn(node, (contextNode, context) => {
|
|
470
526
|
// A nested execution starts fresh: an enclosing statement's redirect is
|
|
471
527
|
// that statement's, not the substitution's, exactly as #807 attributes a
|
|
@@ -475,7 +531,12 @@ function collectHostedCommands(node: TSNode, out: BashCommand[]): void {
|
|
|
475
531
|
// units carry the mark regardless, so the verdict is unchanged (#840).
|
|
476
532
|
descendCommandChildren(
|
|
477
533
|
contextNode,
|
|
478
|
-
{
|
|
534
|
+
{
|
|
535
|
+
context,
|
|
536
|
+
writesViaRedirect: false,
|
|
537
|
+
parseUnresolved: false,
|
|
538
|
+
salvaged: scope.salvaged,
|
|
539
|
+
},
|
|
479
540
|
out,
|
|
480
541
|
);
|
|
481
542
|
});
|
|
@@ -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;
|
|
@@ -77,16 +79,32 @@ export function parseUnresolvedAt(node: TSNode): boolean {
|
|
|
77
79
|
* fact about redirects, not about statements: a statement whose *predecessor*
|
|
78
80
|
* failed is not itself unparsed, and borrowing the wider predicate here would
|
|
79
81
|
* condemn every statement following a failed one.
|
|
82
|
+
*
|
|
83
|
+
* `unresolved-salvage.ts` asks the same question twice over: to locate the
|
|
84
|
+
* innermost region worth re-parsing, and to refuse the re-parse's own result
|
|
85
|
+
* when it failed too (#875).
|
|
80
86
|
*/
|
|
81
87
|
export function parseUnresolvedWithin(node: TSNode): boolean {
|
|
82
88
|
return node.hasError;
|
|
83
89
|
}
|
|
84
90
|
|
|
85
91
|
/**
|
|
86
|
-
*
|
|
92
|
+
* The one parse capability a consumer needs to re-parse a fragment of a
|
|
93
|
+
* command on its own.
|
|
94
|
+
*
|
|
95
|
+
* Narrower than {@link TSParser} on purpose: that interface also carries the
|
|
96
|
+
* parser's own `delete()`, which destroys the process-wide memoized parser for
|
|
97
|
+
* every later command. A consumer re-parsing a fragment has no business
|
|
98
|
+
* holding that, so it takes this instead (`unresolved-salvage.ts`, #875).
|
|
87
99
|
*/
|
|
88
|
-
interface
|
|
100
|
+
export interface BashReparser {
|
|
89
101
|
parse(input: string): { rootNode: TSNode; delete(): void } | null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Minimal subset of web-tree-sitter's Parser used by this module.
|
|
106
|
+
*/
|
|
107
|
+
interface TSParser extends BashReparser {
|
|
90
108
|
delete(): void;
|
|
91
109
|
}
|
|
92
110
|
|
|
@@ -4,8 +4,13 @@ import {
|
|
|
4
4
|
BashPathResolver,
|
|
5
5
|
type BashPathRuleCandidate,
|
|
6
6
|
} from "./bash-path-resolver";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type BashCommand,
|
|
9
|
+
collectCommands,
|
|
10
|
+
collectSalvagedCommands,
|
|
11
|
+
} from "./command-enumeration";
|
|
8
12
|
import { getParser } from "./parser";
|
|
13
|
+
import { withSalvagedRoots } from "./unresolved-salvage";
|
|
9
14
|
|
|
10
15
|
export type { BashCommand, BashExternalPath, BashPathRuleCandidate };
|
|
11
16
|
|
|
@@ -56,16 +61,21 @@ export class BashProgram {
|
|
|
56
61
|
if (!tree) return new BashProgram(command, [], [], []);
|
|
57
62
|
|
|
58
63
|
try {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
64
|
+
return withSalvagedRoots(tree.rootNode, parser, (salvaged) => {
|
|
65
|
+
const { externalAccesses, ruleCandidates } = new BashPathResolver(
|
|
66
|
+
normalizer,
|
|
67
|
+
options?.workdir,
|
|
68
|
+
).resolve(tree.rootNode, salvaged);
|
|
69
|
+
return new BashProgram(
|
|
70
|
+
command,
|
|
71
|
+
[
|
|
72
|
+
...collectCommands(tree.rootNode),
|
|
73
|
+
...salvaged.flatMap(collectSalvagedCommands),
|
|
74
|
+
],
|
|
75
|
+
externalAccesses,
|
|
76
|
+
ruleCandidates,
|
|
77
|
+
);
|
|
78
|
+
});
|
|
69
79
|
} finally {
|
|
70
80
|
tree.delete();
|
|
71
81
|
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
type BashCommand,
|
|
3
|
+
collectCommands,
|
|
4
|
+
collectSalvagedCommands,
|
|
5
|
+
} from "./command-enumeration";
|
|
2
6
|
import { getWarmBashParser } from "./parser";
|
|
7
|
+
import { withSalvagedRoots } from "./unresolved-salvage";
|
|
3
8
|
|
|
4
9
|
/**
|
|
5
10
|
* Synchronously enumerate the command-pattern units of a bash command using the
|
|
@@ -21,7 +26,10 @@ export function parseBashCommandsSync(command: string): BashCommand[] | null {
|
|
|
21
26
|
const tree = parser.parse(command);
|
|
22
27
|
if (!tree) return [];
|
|
23
28
|
try {
|
|
24
|
-
return
|
|
29
|
+
return withSalvagedRoots(tree.rootNode, parser, (salvaged) => [
|
|
30
|
+
...collectCommands(tree.rootNode),
|
|
31
|
+
...salvaged.flatMap(collectSalvagedCommands),
|
|
32
|
+
]);
|
|
25
33
|
} finally {
|
|
26
34
|
tree.delete();
|
|
27
35
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type BashReparser,
|
|
3
|
+
parseUnresolvedWithin,
|
|
4
|
+
type TSNode,
|
|
5
|
+
} from "./parser";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Run `use` over the roots of every region the primary parse could not resolve
|
|
9
|
+
* but which re-parses cleanly on its own, then delete the trees it created.
|
|
10
|
+
*
|
|
11
|
+
* `tree-sitter-bash` 0.25.1 cannot parse a heredoc redirect combined with
|
|
12
|
+
* `2>&1` **and** a pipe, though each pairing alone is fine. Its recovery hangs
|
|
13
|
+
* an `ERROR` node holding only the `|` under `heredoc_redirect → file_redirect`
|
|
14
|
+
* and leaves the piped command's words as plain siblings of it — and
|
|
15
|
+
* `heredoc_redirect` is an execution host, descended for the substitutions it
|
|
16
|
+
* may carry and never read for text. So `git commit -F - <<'MSG' 2>&1 | rm -rf
|
|
17
|
+
* /tmp/x` enumerates `git commit -F` and nothing else, and a configured
|
|
18
|
+
* `bash: {"rm -rf *": "deny"}` is never evaluated against a command that
|
|
19
|
+
* really runs (#875).
|
|
20
|
+
*
|
|
21
|
+
* Re-parsing the dropped region's own source text recovers it, because the
|
|
22
|
+
* grammar gap is in the *combination* — `2>&1 | rm -rf /tmp/x` parses
|
|
23
|
+
* perfectly on its own.
|
|
24
|
+
*
|
|
25
|
+
* The roots are handed to a callback rather than returned because each belongs
|
|
26
|
+
* to a tree that must outlive its use and be released afterwards, exactly as
|
|
27
|
+
* the primary parse's caller already does for its own tree.
|
|
28
|
+
*
|
|
29
|
+
* Salvaging is purely additive: a caller enumerates these roots *in addition
|
|
30
|
+
* to* the primary one, so the result can only ever be more restrictive.
|
|
31
|
+
*/
|
|
32
|
+
export function withSalvagedRoots<T>(
|
|
33
|
+
primary: TSNode,
|
|
34
|
+
reparser: BashReparser,
|
|
35
|
+
use: (salvaged: readonly TSNode[]) => T,
|
|
36
|
+
): T {
|
|
37
|
+
const trees: { rootNode: TSNode; delete(): void }[] = [];
|
|
38
|
+
try {
|
|
39
|
+
for (const candidate of unresolvedRegionsWithin(primary)) {
|
|
40
|
+
const tree = reparser.parse(candidate.text);
|
|
41
|
+
if (!tree) continue;
|
|
42
|
+
// The whole safety argument: tree-sitter's error recovery *invents* the
|
|
43
|
+
// structure inside an unresolved region (#742), and invented structure
|
|
44
|
+
// does not re-parse. Without this check `cat <> rw.txt` salvages a
|
|
45
|
+
// command unit whose text is `">"`, matched against the bash rules like
|
|
46
|
+
// any real command.
|
|
47
|
+
if (parseUnresolvedWithin(tree.rootNode)) {
|
|
48
|
+
tree.delete();
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
trees.push(tree);
|
|
52
|
+
}
|
|
53
|
+
return use(trees.map(({ rootNode }) => rootNode));
|
|
54
|
+
} finally {
|
|
55
|
+
for (const tree of trees) tree.delete();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The innermost nodes beneath `root` whose subtree the parser could not
|
|
61
|
+
* resolve, in source order.
|
|
62
|
+
*
|
|
63
|
+
* Three exclusions shape the answer.
|
|
64
|
+
*
|
|
65
|
+
* An `ERROR` node is never a candidate and is never descended in search of
|
|
66
|
+
* one: its interior is recovery's invention rather than anything observed
|
|
67
|
+
* (#742), so the region worth re-parsing is the node that *holds* it.
|
|
68
|
+
*
|
|
69
|
+
* Only the innermost such node is offered. An enclosing statement reports the
|
|
70
|
+
* error too, and its text re-parses to the same failure, so offering it
|
|
71
|
+
* salvages nothing while burying the fragment that would have worked.
|
|
72
|
+
*
|
|
73
|
+
* `root` itself is never a candidate, for the same reason taken to its limit:
|
|
74
|
+
* re-parsing the whole source reproduces the whole failure by construction.
|
|
75
|
+
*/
|
|
76
|
+
function unresolvedRegionsWithin(root: TSNode): TSNode[] {
|
|
77
|
+
const found: TSNode[] = [];
|
|
78
|
+
collectInnermostUnresolved(root, root, found);
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function collectInnermostUnresolved(
|
|
83
|
+
node: TSNode,
|
|
84
|
+
root: TSNode,
|
|
85
|
+
found: TSNode[],
|
|
86
|
+
): void {
|
|
87
|
+
if (!parseUnresolvedWithin(node)) return;
|
|
88
|
+
const before = found.length;
|
|
89
|
+
for (let i = 0; i < node.childCount; i++) {
|
|
90
|
+
const child = node.child(i);
|
|
91
|
+
if (child && child.type !== "ERROR") {
|
|
92
|
+
collectInnermostUnresolved(child, root, found);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const foundDeeper = found.length > before;
|
|
96
|
+
if (!foundDeeper && node !== root && node.type !== "ERROR") found.push(node);
|
|
97
|
+
}
|
|
@@ -30,15 +30,19 @@ import type { PermissionCheckResult } from "#src/types";
|
|
|
30
30
|
* from riding a permissive rule; an explicit `deny`/`ask` on the wrapper is left
|
|
31
31
|
* untouched (`deny > ask > allow`).
|
|
32
32
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
33
|
+
* A trivially-empty command (an empty, whitespace-only, or comment-only line)
|
|
34
|
+
* has genuinely nothing to gate, so the whole `command` is resolved as before.
|
|
35
|
+
*
|
|
36
|
+
* When the *primary* parse matched nothing, the whole command string is the
|
|
37
|
+
* only surface an explicit `deny` can reach, so it is resolved first and a
|
|
38
|
+
* `deny` covering it denies outright rather than being masked into an
|
|
39
|
+
* approvable prompt (#712). With no units at all the result also fails closed
|
|
40
|
+
* to a synthetic `ask`, so a permissive top-level `*` cannot silently allow an
|
|
38
41
|
* unparseable command (e.g. `cd /repo && git push` riding a top-level allow on
|
|
39
|
-
* the empty-parse path) — #452.
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
+
* the empty-parse path) — #452. A command whose units are *all* salvaged
|
|
43
|
+
* (#875) takes the same whole-string check, because its primary parse matched
|
|
44
|
+
* nothing either; only the synthetic `ask` is skipped, since the recovered
|
|
45
|
+
* units now carry the verdict.
|
|
42
46
|
*
|
|
43
47
|
* A *partial* parse failure is the other half of that clause: the units the
|
|
44
48
|
* recovery produced are enumerated normally, and any one the enumerator marked
|
|
@@ -70,22 +74,32 @@ export function resolveBashCommandCheck(
|
|
|
70
74
|
agentName: string | undefined,
|
|
71
75
|
resolver: ScopedPermissionResolver,
|
|
72
76
|
): PermissionCheckResult {
|
|
73
|
-
if (
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
if (isTriviallyEmptyCommand(command)) {
|
|
78
|
+
return resolveOnBashSurface(command, agentName, resolver);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!commands.some((cmd) => cmd.salvaged !== true)) {
|
|
82
|
+
// The primary parse matched nothing, so the whole command string is the
|
|
83
|
+
// only surface an explicit `deny` can reach (#452, #712) — a rule naming
|
|
84
|
+
// the command in context (`"* rm -rf *"`) matches the string and not the
|
|
85
|
+
// fragment. This runs whether or not the salvage went on to recover units
|
|
86
|
+
// from the wreckage: `> f <<'M' 2>&1 | rm -rf /tmp/x` has zero primary
|
|
87
|
+
// units and one salvaged one, and keying the check on the combined list
|
|
88
|
+
// would silently drop a `deny` the pre-salvage gate reached (#875).
|
|
77
89
|
const whole = resolveOnBashSurface(command, agentName, resolver);
|
|
78
90
|
if (whole.state === "deny") {
|
|
79
91
|
return whole;
|
|
80
92
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
93
|
+
if (commands.length === 0) {
|
|
94
|
+
return {
|
|
95
|
+
state: "ask",
|
|
96
|
+
toolName: "bash",
|
|
97
|
+
source: "bash",
|
|
98
|
+
origin: "builtin",
|
|
99
|
+
command,
|
|
100
|
+
matchedPattern: "<unparseable-bash-command>",
|
|
101
|
+
};
|
|
102
|
+
}
|
|
89
103
|
}
|
|
90
104
|
|
|
91
105
|
const results = commands.map((cmd) =>
|
|
@@ -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;
|