@andares/pi-toolkits 0.9.7 → 0.10.0
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/README.md +41 -12
- package/README.zh-CN.md +42 -12
- package/package.json +6 -5
- package/src/features/ask/reasons.ts +1 -1
- package/src/features/autostart/autostart.ts +1 -1
- package/src/features/autostart/index.ts +1 -1
- package/src/features/compact/compact.test.ts +33 -10
- package/src/features/compact/index.ts +27 -34
- package/src/features/compact/prompt.ts +23 -12
- package/src/features/favorites/constants.ts +6 -5
- package/src/features/favorites/patch-cycle.ts +4 -3
- package/src/features/favorites/patch-selector.ts +2 -2
- package/src/features/permission/audit.test.ts +159 -0
- package/src/features/permission/audit.ts +145 -0
- package/src/features/permission/index.test.ts +138 -3
- package/src/features/permission/index.ts +108 -12
- package/src/features/permission/policy.ts +39 -3
- package/src/features/stash/stash.ts +1 -1
- package/src/index.ts +6 -2
- package/src/lib/approval-log.test.ts +289 -0
- package/src/lib/approval-log.ts +724 -0
- package/src/lib/gates.audit.test.ts +343 -0
- package/src/lib/gates.ts +295 -10
- package/src/lib/model-config.ts +2 -2
- package/src/lib/model-picker.ts +1 -1
|
@@ -6,14 +6,15 @@
|
|
|
6
6
|
* summaries produced by the configured compact model are formatted exactly
|
|
7
7
|
* like pi's own compaction output. Only the executing model differs.
|
|
8
8
|
* Verified byte-identical against every published version of the supported
|
|
9
|
-
* window (1.
|
|
10
|
-
*
|
|
11
|
-
* utils.js change (1.2.0 dropping assistant thinking from
|
|
9
|
+
* window (1.1.0 → 1.4.0): core/usage-totals.js is unchanged across that
|
|
10
|
+
* span, the only utils.js change (1.2.0 dropping assistant thinking from
|
|
12
11
|
* serializeConversation) does not touch the strings mirrored here — that
|
|
13
|
-
* serializer is imported from the host, not mirrored
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
12
|
+
* serializer is imported from the host, not mirrored — and compaction.js
|
|
13
|
+
* changed once, in 1.4.0, in the output budget and the split-turn focus
|
|
14
|
+
* handling (both adopted here, see buildTurnPrefixPromptText). 0.87.0
|
|
15
|
+
* shipped a different split-turn prefix prompt; the text mirrored here is
|
|
16
|
+
* 0.87.1's fix, and the mirror is the only variant any supported host gets
|
|
17
|
+
* — intentional.
|
|
17
18
|
*
|
|
18
19
|
* Update these strings whenever pi changes its built-in prompts (diff
|
|
19
20
|
* dist/core/compaction/compaction.js between versions).
|
|
@@ -146,11 +147,21 @@ export function buildHistoryPromptText(
|
|
|
146
147
|
|
|
147
148
|
/**
|
|
148
149
|
* Build the split-turn prefix prompt text (pi 0.87.1
|
|
149
|
-
* generateTurnPrefixSummary): `# Conversation` block
|
|
150
|
-
* `# Instructions
|
|
150
|
+
* generateTurnPrefixSummary, extended in 1.4.0): `# Conversation` block
|
|
151
|
+
* followed by `# Instructions`, with the `/compact <instructions>` focus
|
|
152
|
+
* appended as `Additional focus: ...`. 1.4.0 added that last line (#11058:
|
|
153
|
+
* the focus used to be dropped when the split turn was the only new
|
|
154
|
+
* content since the last compaction); we use the fixed form on every host.
|
|
151
155
|
*/
|
|
152
|
-
export function buildTurnPrefixPromptText(
|
|
153
|
-
|
|
156
|
+
export function buildTurnPrefixPromptText(
|
|
157
|
+
conversationText: string,
|
|
158
|
+
customInstructions?: string,
|
|
159
|
+
): string {
|
|
160
|
+
let promptText = `# Conversation\n${conversationText}\n\n# Instructions\n${TURN_PREFIX_SUMMARIZATION_PROMPT}`;
|
|
161
|
+
if (customInstructions) {
|
|
162
|
+
promptText = `${promptText}\n\nAdditional focus: ${customInstructions}`;
|
|
163
|
+
}
|
|
164
|
+
return promptText;
|
|
154
165
|
}
|
|
155
166
|
|
|
156
167
|
/** File-operation sets extracted by pi's prepareCompaction. */
|
|
@@ -203,7 +214,7 @@ export function formatFileOperations(
|
|
|
203
214
|
/**
|
|
204
215
|
* Combine two provider usage records field-wise (pi 0.87.1 compaction.js
|
|
205
216
|
* combineUsage, moved verbatim to core/usage-totals.js in 0.99.0 and
|
|
206
|
-
* unchanged through 1.
|
|
217
|
+
* unchanged through 1.4.0): used when
|
|
207
218
|
* a split turn produces two summarization calls.
|
|
208
219
|
*/
|
|
209
220
|
export function combineUsage<T extends UsageLike>(first: T, second: T): T {
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* bindings — only the cycle semantics change (patch-cycle.ts), so no
|
|
9
9
|
* keybinding conflicts arise.
|
|
10
10
|
*
|
|
11
|
-
* Verified against every published version of the supported
|
|
12
|
-
* (
|
|
11
|
+
* Verified against every published version of the supported range
|
|
12
|
+
* (0.99.0 → 1.4.0) keybinding
|
|
13
13
|
* tables + pi-tui input dispatch on WSL2 + Windows Terminal; re-run
|
|
14
14
|
* `pnpm check:keybindings` after every host upgrade before trusting this.
|
|
15
15
|
*/
|
|
@@ -30,11 +30,12 @@ export const FAVORITES_FILE = "pi-toolkits-favorites.json";
|
|
|
30
30
|
* Windows/WSL `tui.altScreen.search` resolves to ctrl+f, and
|
|
31
31
|
* `TuiAltScreen.handleViewportInput()` checks it before
|
|
32
32
|
* `shouldDeferViewportInputToOverlay()`, so the keystroke opens transcript
|
|
33
|
-
* search and the selector never sees it (verified in pi-tui 1.
|
|
33
|
+
* search and the selector never sees it (verified in pi-tui 1.4.0
|
|
34
34
|
* `dist/tui-alt-screen.js`: the search check is the first key branch and
|
|
35
|
-
* returns `{ consume: true }` unconditionally
|
|
35
|
+
* returns `{ consume: true }` unconditionally; the 1.4.0 changes to that
|
|
36
|
+
* file are exit/teardown only).
|
|
36
37
|
*
|
|
37
|
-
* Why ctrl+alt+f was discarded: clean at the pi layer across 1.
|
|
38
|
+
* Why ctrl+alt+f was discarded: clean at the pi layer across 1.1–1.4 (only
|
|
38
39
|
* ctrl+alt+] is taken from that family) and byte-distinguishable without
|
|
39
40
|
* kitty — but grabbed at the OS/app layer on the author's machine by a
|
|
40
41
|
* Windows screenshot tool, so it never reached the terminal. Lesson: a key
|
|
@@ -15,7 +15,8 @@
|
|
|
15
15
|
* model available" feedback); otherwise jump to it
|
|
16
16
|
* - the apply logic mirrors pi's _cycleScopedModel/_cycleAvailableModel
|
|
17
17
|
* (byte-identical across every published version in the supported window
|
|
18
|
-
* 1.
|
|
18
|
+
* 1.1.0 → 1.4.0 — `cycleModel` itself is untouched by 1.4.0's recovery and
|
|
19
|
+
* tool-restore rewrites:
|
|
19
20
|
* state swap, session persistence, default-model persistence only when
|
|
20
21
|
* `options.persist` is set, thinking-level re-clamp, model_select emit
|
|
21
22
|
* with source "cycle")
|
|
@@ -31,9 +32,9 @@ import { getFavoritesStore, modelKey } from "./store.js";
|
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* pi's `Model<any>` — the exact type of `ModelCycleResult.model`
|
|
34
|
-
* (pi-coding-agent 1.
|
|
35
|
+
* (pi-coding-agent 1.1–1.4; the field is unchanged across that span).
|
|
35
36
|
* Derived here instead of importing `Model` from @earendil-works/pi-ai,
|
|
36
|
-
* whose index.d.ts (1.
|
|
37
|
+
* whose index.d.ts (1.1 through 1.4.0 alike) re-exports it via an
|
|
37
38
|
* extensioned `"./types.ts"` specifier that not every TS resolver substitutes
|
|
38
39
|
* to `types.d.ts` (tsc 5.9 bundler does; other tsserver builds don't).
|
|
39
40
|
*/
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
* upgraded internals), patches are skipped with a warning — the extension
|
|
22
22
|
* keeps loading and ctrl+p falls back to stock behavior. The prototype
|
|
23
23
|
* shape touched here (handleInput/updateList/filterModels and the instance
|
|
24
|
-
* fields below) is byte-identical across
|
|
25
|
-
* pi 1.
|
|
24
|
+
* fields below) is byte-identical across the mandatory window
|
|
25
|
+
* pi 1.1.0 → 1.4.0 (and exercised by the 0.99.0 → 1.0.4 backward probes).
|
|
26
26
|
*/
|
|
27
27
|
import {
|
|
28
28
|
ModelSelectorComponent,
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission audit payload: the decomposition, the scope/effect of every
|
|
3
|
+
* target, and the machine-readable rule layer must survive into the log.
|
|
4
|
+
*/
|
|
5
|
+
import { mkdtempSync, realpathSync } from "node:fs";
|
|
6
|
+
import { homedir, tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { describe, expect, it } from "vitest";
|
|
9
|
+
import { buildPermissionAudit, commandDigest } from "./audit.js";
|
|
10
|
+
import { DEFAULT_MODES, type PermissionModes } from "./constants.js";
|
|
11
|
+
import { canonicalize } from "./paths.js";
|
|
12
|
+
import {
|
|
13
|
+
decideProgram,
|
|
14
|
+
decideUnit,
|
|
15
|
+
type PolicyContext,
|
|
16
|
+
} from "./policy.js";
|
|
17
|
+
import { SessionRules } from "./session-rules.js";
|
|
18
|
+
import { parseCommandUnits } from "./units.js";
|
|
19
|
+
|
|
20
|
+
function project(): string {
|
|
21
|
+
return realpathSync(mkdtempSync(join(tmpdir(), "pi-perm-audit-")));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function makeContext(
|
|
25
|
+
cwd: string,
|
|
26
|
+
modes: PermissionModes = DEFAULT_MODES,
|
|
27
|
+
rules = new SessionRules(),
|
|
28
|
+
): PolicyContext {
|
|
29
|
+
return { modes, roots: [canonicalize(cwd)], rules };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function audit(command: string, ctx: PolicyContext, prompt?: readonly string[]) {
|
|
33
|
+
const units = parseCommandUnits(command, ctx.roots[0] ?? "/");
|
|
34
|
+
return buildPermissionAudit({
|
|
35
|
+
cwd: ctx.roots[0] ?? "/",
|
|
36
|
+
modes: ctx.modes,
|
|
37
|
+
roots: ctx.roots,
|
|
38
|
+
units,
|
|
39
|
+
unitDecisions: units.map((unit) => decideUnit(unit, ctx)),
|
|
40
|
+
decision: decideProgram(units, ctx),
|
|
41
|
+
...(prompt === undefined ? {} : { prompt }),
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("permission audit payload", () => {
|
|
46
|
+
it("records the decomposition, target scope and deciding layer", () => {
|
|
47
|
+
const cwd = project();
|
|
48
|
+
const payload = audit("rm -rf /tmp/outside/x", makeContext(cwd));
|
|
49
|
+
|
|
50
|
+
expect(payload.modes).toEqual({
|
|
51
|
+
inRead: "allow",
|
|
52
|
+
inWrite: "allow",
|
|
53
|
+
outRead: "allow",
|
|
54
|
+
outWrite: "ask",
|
|
55
|
+
});
|
|
56
|
+
expect(payload.roots).toEqual([canonicalize(cwd)]);
|
|
57
|
+
expect(payload.units).toEqual([
|
|
58
|
+
expect.objectContaining({
|
|
59
|
+
head: "rm",
|
|
60
|
+
command: "rm",
|
|
61
|
+
args: ["-rf", "/tmp/outside/x"],
|
|
62
|
+
base: canonicalize(cwd),
|
|
63
|
+
effect: "write",
|
|
64
|
+
opaque: false,
|
|
65
|
+
action: "ask",
|
|
66
|
+
layer: "mode",
|
|
67
|
+
targets: [
|
|
68
|
+
{
|
|
69
|
+
path: "/tmp/outside/x",
|
|
70
|
+
canonical: "/tmp/outside/x",
|
|
71
|
+
effect: "write",
|
|
72
|
+
source: "operand",
|
|
73
|
+
scope: "out",
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
}),
|
|
77
|
+
]);
|
|
78
|
+
expect(payload.verdict).toMatchObject({
|
|
79
|
+
action: "ask",
|
|
80
|
+
layer: "mode",
|
|
81
|
+
unit: 0,
|
|
82
|
+
target: "/tmp/outside/x",
|
|
83
|
+
effect: "write",
|
|
84
|
+
scope: "out",
|
|
85
|
+
grantScope: "root",
|
|
86
|
+
grantRoot: "/tmp/outside",
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("keeps the secret-table layer for a denied credential read", () => {
|
|
91
|
+
const cwd = project();
|
|
92
|
+
const payload = audit(`cat ${homedir()}/.ssh/id_rsa`, makeContext(cwd));
|
|
93
|
+
expect(payload.verdict).toMatchObject({
|
|
94
|
+
action: "deny",
|
|
95
|
+
layer: "secret",
|
|
96
|
+
scope: "out",
|
|
97
|
+
effect: "read",
|
|
98
|
+
});
|
|
99
|
+
expect(String((payload.verdict as Record<string, unknown>).reason)).toContain(
|
|
100
|
+
"fixed safety table",
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("marks an opaque command and records a command-scoped grant", () => {
|
|
105
|
+
const cwd = project();
|
|
106
|
+
const command = 'git -C "$DIR" log';
|
|
107
|
+
const ctx = makeContext(cwd);
|
|
108
|
+
const units = parseCommandUnits(command, cwd);
|
|
109
|
+
const payload = audit(command, ctx);
|
|
110
|
+
|
|
111
|
+
expect(units[0]?.opaque).toBe(true);
|
|
112
|
+
expect((payload.units as Array<Record<string, unknown>>)[0]).toMatchObject({
|
|
113
|
+
opaque: true,
|
|
114
|
+
action: "ask",
|
|
115
|
+
layer: "opaque",
|
|
116
|
+
});
|
|
117
|
+
expect(payload.verdict).toMatchObject({
|
|
118
|
+
action: "ask",
|
|
119
|
+
layer: "opaque",
|
|
120
|
+
grantScope: "command",
|
|
121
|
+
grantDigest: commandDigest(units),
|
|
122
|
+
});
|
|
123
|
+
expect(
|
|
124
|
+
(payload.verdict as Record<string, unknown>).grantRoot,
|
|
125
|
+
).toBeUndefined();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("records an in-project mode allow with its layer", () => {
|
|
129
|
+
const cwd = project();
|
|
130
|
+
const payload = audit(`rm -rf ${cwd}/build`, makeContext(cwd));
|
|
131
|
+
expect(payload.verdict).toMatchObject({
|
|
132
|
+
action: "allow",
|
|
133
|
+
layer: "mode",
|
|
134
|
+
scope: "in",
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("carries the exact prompt lines when asked", () => {
|
|
139
|
+
const cwd = project();
|
|
140
|
+
const payload = audit("rm -rf /tmp/outside/x", makeContext(cwd), [
|
|
141
|
+
"Permission required",
|
|
142
|
+
"Command: rm -rf /tmp/outside/x",
|
|
143
|
+
]);
|
|
144
|
+
expect(payload.prompt).toEqual([
|
|
145
|
+
"Permission required",
|
|
146
|
+
"Command: rm -rf /tmp/outside/x",
|
|
147
|
+
]);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("clips long arguments but keeps the target", () => {
|
|
151
|
+
const cwd = project();
|
|
152
|
+
const longArg = `--flag=${"x".repeat(2000)}`;
|
|
153
|
+
const payload = audit(`rm -rf /tmp/outside/x ${longArg}`, makeContext(cwd));
|
|
154
|
+
const unit = (payload.units as Array<Record<string, unknown>>)[0];
|
|
155
|
+
const args = unit.args as string[];
|
|
156
|
+
expect(args[2]).toContain("[+");
|
|
157
|
+
expect(unit.targets).toHaveLength(1);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* permission feature — the audit payload.
|
|
3
|
+
*
|
|
4
|
+
* The gate bus owns the record envelope (session, tool call, answer, outcome);
|
|
5
|
+
* this module contributes the part only the permission layer knows: the four
|
|
6
|
+
* modes and session roots in force, the bash decomposition, and which rule
|
|
7
|
+
* produced the verdict.
|
|
8
|
+
*
|
|
9
|
+
* The payload is deliberately lossless for *policy analysis* — every unit and
|
|
10
|
+
* every target survives, including the parser's `opaque` note — while being
|
|
11
|
+
* bounded: units, targets, args and long strings are capped, and the final
|
|
12
|
+
* serializer drops detail with a visible `truncated` marker rather than
|
|
13
|
+
* silently cutting a field. File contents never enter this payload.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import type { ApprovalRecord } from "../../lib/approval-log.js";
|
|
17
|
+
import { clipText } from "../../lib/approval-log.js";
|
|
18
|
+
import type { PermissionModes } from "./constants.js";
|
|
19
|
+
import { canonicalize, insideAnyRoot } from "./paths.js";
|
|
20
|
+
import {
|
|
21
|
+
decisionGrantRoot,
|
|
22
|
+
type PolicyDecision,
|
|
23
|
+
} from "./policy.js";
|
|
24
|
+
import type { CommandUnit } from "./units.js";
|
|
25
|
+
|
|
26
|
+
const MAX_UNITS = 32;
|
|
27
|
+
const MAX_TARGETS = 32;
|
|
28
|
+
const MAX_ARGS = 64;
|
|
29
|
+
const ARG_CHARS = 512;
|
|
30
|
+
const RAW_CHARS = 8192;
|
|
31
|
+
const PATH_CHARS = 1024;
|
|
32
|
+
const NOTE_CHARS = 512;
|
|
33
|
+
const REASON_CHARS = 2048;
|
|
34
|
+
|
|
35
|
+
export interface PermissionAuditInput {
|
|
36
|
+
readonly cwd: string;
|
|
37
|
+
readonly modes: PermissionModes;
|
|
38
|
+
/** Canonical session roots (working directory plus added directories). */
|
|
39
|
+
readonly roots: readonly string[];
|
|
40
|
+
readonly units: readonly CommandUnit[];
|
|
41
|
+
/** Verdict for each unit, in the same order as {@link units}. */
|
|
42
|
+
readonly unitDecisions: readonly PolicyDecision[];
|
|
43
|
+
/** Aggregate verdict (the strictest unit wins). */
|
|
44
|
+
readonly decision: PolicyDecision;
|
|
45
|
+
/** Exact dialog lines, when this payload belongs to an ask. */
|
|
46
|
+
readonly prompt?: readonly string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Short digest of the command text, for grouping opaque grants. */
|
|
50
|
+
export function commandDigest(units: readonly CommandUnit[]): string {
|
|
51
|
+
return createHash("sha256")
|
|
52
|
+
.update(units.map((unit) => unit.raw).join(" ; "))
|
|
53
|
+
.digest("hex")
|
|
54
|
+
.slice(0, 16);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function serializeUnit(
|
|
58
|
+
unit: CommandUnit,
|
|
59
|
+
decision: PolicyDecision | undefined,
|
|
60
|
+
roots: readonly string[],
|
|
61
|
+
): ApprovalRecord {
|
|
62
|
+
const targets = unit.targets.slice(0, MAX_TARGETS).map((target) => {
|
|
63
|
+
const canonical = canonicalize(target.path);
|
|
64
|
+
return {
|
|
65
|
+
path: clipText(target.path, PATH_CHARS),
|
|
66
|
+
canonical: clipText(canonical, PATH_CHARS),
|
|
67
|
+
effect: target.effect,
|
|
68
|
+
source: target.source,
|
|
69
|
+
scope: insideAnyRoot(roots, canonical) ? "in" : "out",
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
return {
|
|
73
|
+
raw: clipText(unit.raw, RAW_CHARS),
|
|
74
|
+
head: unit.headWord,
|
|
75
|
+
command: unit.command,
|
|
76
|
+
args: unit.args.slice(0, MAX_ARGS).map((arg) => clipText(arg, ARG_CHARS)),
|
|
77
|
+
base: unit.base === null ? null : clipText(canonicalize(unit.base), PATH_CHARS),
|
|
78
|
+
effect: unit.effect,
|
|
79
|
+
opaque: unit.opaque,
|
|
80
|
+
...(unit.note === undefined ? {} : { note: clipText(unit.note, NOTE_CHARS) }),
|
|
81
|
+
...(decision === undefined
|
|
82
|
+
? {}
|
|
83
|
+
: { action: decision.action, layer: decision.layer }),
|
|
84
|
+
targets,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function serializeVerdict(
|
|
89
|
+
input: PermissionAuditInput,
|
|
90
|
+
): ApprovalRecord {
|
|
91
|
+
const decision = input.decision;
|
|
92
|
+
const unitIndex = input.units.indexOf(decision.unit);
|
|
93
|
+
const target = decision.target;
|
|
94
|
+
const grantScope = decision.unit.opaque ? "command" : "root";
|
|
95
|
+
return {
|
|
96
|
+
action: decision.action,
|
|
97
|
+
layer: decision.layer,
|
|
98
|
+
...(unitIndex < 0 ? {} : { unit: unitIndex }),
|
|
99
|
+
...(target === undefined
|
|
100
|
+
? {}
|
|
101
|
+
: {
|
|
102
|
+
target: clipText(target.path, PATH_CHARS),
|
|
103
|
+
effect: target.effect,
|
|
104
|
+
scope: target.scope,
|
|
105
|
+
}),
|
|
106
|
+
grantScope,
|
|
107
|
+
...(grantScope === "root"
|
|
108
|
+
? {
|
|
109
|
+
grantRoot: clipText(
|
|
110
|
+
decisionGrantRoot(decision, () => input.cwd),
|
|
111
|
+
PATH_CHARS,
|
|
112
|
+
),
|
|
113
|
+
}
|
|
114
|
+
: { grantDigest: commandDigest(input.units) }),
|
|
115
|
+
...(decision.reason === undefined
|
|
116
|
+
? {}
|
|
117
|
+
: { reason: clipText(decision.reason, REASON_CHARS) }),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Structured audit fields for one permission decision. */
|
|
122
|
+
export function buildPermissionAudit(
|
|
123
|
+
input: PermissionAuditInput,
|
|
124
|
+
): ApprovalRecord {
|
|
125
|
+
const unitDecisions = input.unitDecisions;
|
|
126
|
+
return {
|
|
127
|
+
modes: {
|
|
128
|
+
inRead: input.modes.inRead,
|
|
129
|
+
inWrite: input.modes.inWrite,
|
|
130
|
+
outRead: input.modes.outRead,
|
|
131
|
+
outWrite: input.modes.outWrite,
|
|
132
|
+
},
|
|
133
|
+
roots: input.roots
|
|
134
|
+
.slice(0, 16)
|
|
135
|
+
.map((root) => clipText(root, PATH_CHARS)),
|
|
136
|
+
units: input.units
|
|
137
|
+
.slice(0, MAX_UNITS)
|
|
138
|
+
.map((unit, index) => serializeUnit(unit, unitDecisions[index], input.roots)),
|
|
139
|
+
...(input.units.length > MAX_UNITS
|
|
140
|
+
? { unitsOmitted: input.units.length - MAX_UNITS }
|
|
141
|
+
: {}),
|
|
142
|
+
verdict: serializeVerdict(input),
|
|
143
|
+
...(input.prompt === undefined ? {} : { prompt: [...input.prompt] }),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* selector, and the rest through the gate's verdicts and the command handler.
|
|
7
7
|
*/
|
|
8
8
|
import { mkdtempSync, realpathSync } from "node:fs";
|
|
9
|
-
import { tmpdir } from "node:os";
|
|
9
|
+
import { homedir, tmpdir } from "node:os";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
12
12
|
import type {
|
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
ExtensionContext,
|
|
15
15
|
ToolCallEvent,
|
|
16
16
|
} from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import type { ApprovalLog, ApprovalRecord } from "../../lib/approval-log.js";
|
|
17
18
|
import type { AskDecision, GateHub, ToolGate } from "../../lib/gates.js";
|
|
18
19
|
import {
|
|
19
20
|
clearJailedTool,
|
|
@@ -65,7 +66,7 @@ interface Harness {
|
|
|
65
66
|
banner(): { systemPrompt: string } | undefined;
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
function makeHarness(): Harness {
|
|
69
|
+
function makeHarness(audit?: ApprovalLog): Harness {
|
|
69
70
|
const cwd = realpathSync(mkdtempSync(join(tmpdir(), "pi-perm-test-")));
|
|
70
71
|
const statuses: Harness["statuses"] = [];
|
|
71
72
|
const notes: Harness["notes"] = [];
|
|
@@ -116,7 +117,7 @@ function makeHarness(): Harness {
|
|
|
116
117
|
registerEntryRenderer() {},
|
|
117
118
|
} as unknown as ExtensionAPI;
|
|
118
119
|
|
|
119
|
-
registerPermission(pi, hub);
|
|
120
|
+
registerPermission(pi, hub, { audit });
|
|
120
121
|
const gate = captured[0];
|
|
121
122
|
if (!gate || !commandHandler) throw new Error("permission did not register");
|
|
122
123
|
|
|
@@ -154,6 +155,7 @@ interface AskVerdict {
|
|
|
154
155
|
request: {
|
|
155
156
|
details: readonly string[];
|
|
156
157
|
root: string;
|
|
158
|
+
audit?: ApprovalRecord;
|
|
157
159
|
present(ctx: ExtensionContext): Promise<AskDecision | undefined>;
|
|
158
160
|
apply(decision: AskDecision): void;
|
|
159
161
|
};
|
|
@@ -435,3 +437,136 @@ describe("self-jailed tools", () => {
|
|
|
435
437
|
expect(asAsk(await h.evaluate(customEvent("scratchpad"))).kind).toBe("ask");
|
|
436
438
|
});
|
|
437
439
|
});
|
|
440
|
+
|
|
441
|
+
describe("audit log", () => {
|
|
442
|
+
function makeAudit(): { audit: ApprovalLog; records: ApprovalRecord[] } {
|
|
443
|
+
const records: ApprovalRecord[] = [];
|
|
444
|
+
return {
|
|
445
|
+
records,
|
|
446
|
+
audit: {
|
|
447
|
+
dir: "/tmp/pi-perm-audit",
|
|
448
|
+
mode: "interesting",
|
|
449
|
+
active: true,
|
|
450
|
+
append: (record) => records.push(record),
|
|
451
|
+
},
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
it("attaches the decomposition to an ask verdict", async () => {
|
|
456
|
+
const { audit } = makeAudit();
|
|
457
|
+
const h = makeHarness(audit);
|
|
458
|
+
await h.command("allow allow allow ask");
|
|
459
|
+
const verdict = asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside/x")));
|
|
460
|
+
expect(verdict.request.audit).toMatchObject({
|
|
461
|
+
modes: { outWrite: "ask" },
|
|
462
|
+
verdict: {
|
|
463
|
+
action: "ask",
|
|
464
|
+
layer: "mode",
|
|
465
|
+
target: "/tmp/outside/x",
|
|
466
|
+
scope: "out",
|
|
467
|
+
},
|
|
468
|
+
});
|
|
469
|
+
expect(
|
|
470
|
+
(verdict.request.audit?.units as Array<Record<string, unknown>>)[0],
|
|
471
|
+
).toMatchObject({
|
|
472
|
+
command: "rm",
|
|
473
|
+
opaque: false,
|
|
474
|
+
action: "ask",
|
|
475
|
+
layer: "mode",
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("attaches the deciding layer to a deny verdict", async () => {
|
|
480
|
+
const { audit } = makeAudit();
|
|
481
|
+
const h = makeHarness(audit);
|
|
482
|
+
await h.command("allow allow allow ask");
|
|
483
|
+
const verdict = (await h.evaluate(
|
|
484
|
+
bashEvent(`cat ${homedir()}/.ssh/id_rsa`),
|
|
485
|
+
)) as {
|
|
486
|
+
kind: "block";
|
|
487
|
+
reason: string;
|
|
488
|
+
audit?: ApprovalRecord;
|
|
489
|
+
};
|
|
490
|
+
expect(verdict.kind).toBe("block");
|
|
491
|
+
expect(verdict.audit?.verdict).toMatchObject({
|
|
492
|
+
action: "deny",
|
|
493
|
+
layer: "secret",
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it("records mode changes with their source", async () => {
|
|
498
|
+
const { audit, records } = makeAudit();
|
|
499
|
+
const h = makeHarness(audit);
|
|
500
|
+
await h.command("allow allow allow ask");
|
|
501
|
+
expect(records.at(-1)).toMatchObject({
|
|
502
|
+
event: "mode",
|
|
503
|
+
enabled: true,
|
|
504
|
+
source: "args",
|
|
505
|
+
via: "manual",
|
|
506
|
+
modes: { inRead: "allow", outWrite: "ask" },
|
|
507
|
+
});
|
|
508
|
+
await h.command("");
|
|
509
|
+
expect(records.at(-1)).toMatchObject({
|
|
510
|
+
event: "mode",
|
|
511
|
+
enabled: false,
|
|
512
|
+
source: "toggle",
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("marks an autostart-dispatched mode change", async () => {
|
|
517
|
+
const { audit, records } = makeAudit();
|
|
518
|
+
const h = makeHarness(audit);
|
|
519
|
+
markStartupCommand("ask-perm");
|
|
520
|
+
await h.command("allow allow allow ask");
|
|
521
|
+
expect(records.at(-1)).toMatchObject({
|
|
522
|
+
event: "mode",
|
|
523
|
+
via: "autostart",
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
describe("audit log — session-grant allows", () => {
|
|
529
|
+
function makeAudit(): { audit: ApprovalLog; records: ApprovalRecord[] } {
|
|
530
|
+
const records: ApprovalRecord[] = [];
|
|
531
|
+
return {
|
|
532
|
+
records,
|
|
533
|
+
audit: {
|
|
534
|
+
dir: "/tmp/pi-perm-audit",
|
|
535
|
+
mode: "interesting",
|
|
536
|
+
active: true,
|
|
537
|
+
append: (record) => records.push(record),
|
|
538
|
+
},
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
it("records the repeat call a directory grant made free", async () => {
|
|
543
|
+
const { audit, records } = makeAudit();
|
|
544
|
+
const h = makeHarness(audit);
|
|
545
|
+
await h.command("allow allow allow ask");
|
|
546
|
+
const first = asAsk(await h.evaluate(bashEvent("rm -rf /tmp/outside/x")));
|
|
547
|
+
first.request.apply("allow-session");
|
|
548
|
+
expect(await h.evaluate(bashEvent("rm -rf /tmp/outside/y"))).toBeUndefined();
|
|
549
|
+
|
|
550
|
+
const allow = records.find((record) => record.event === "allow");
|
|
551
|
+
expect(allow).toMatchObject({
|
|
552
|
+
event: "allow",
|
|
553
|
+
verdict: { action: "allow", layer: "session-allow", scope: "out" },
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("records a program-level grant on an opaque command too", async () => {
|
|
558
|
+
const { audit, records } = makeAudit();
|
|
559
|
+
const h = makeHarness(audit);
|
|
560
|
+
await h.command("allow allow allow ask");
|
|
561
|
+
const command = 'cd "$DIR" && rm x';
|
|
562
|
+
const first = asAsk(await h.evaluate(bashEvent(command)));
|
|
563
|
+
first.request.apply("allow-session");
|
|
564
|
+
expect(await h.evaluate(bashEvent(command))).toBeUndefined();
|
|
565
|
+
|
|
566
|
+
const allow = records.find((record) => record.event === "allow");
|
|
567
|
+
expect(allow).toMatchObject({
|
|
568
|
+
event: "allow",
|
|
569
|
+
verdict: { action: "allow", layer: "session-command", grantScope: "command" },
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
});
|