portable-agent-layer 0.67.0 → 0.69.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 +1 -0
- package/assets/skills/pal-analyze/SKILL.md +1 -1
- package/assets/skills/pal-reflect/SKILL.md +1 -1
- package/assets/skills/projects/SKILL.md +1 -1
- package/assets/skills/telos/SKILL.md +1 -1
- package/assets/templates/hooks.codex.json +18 -4
- package/assets/templates/hooks.copilot.json +31 -12
- package/assets/templates/hooks.cursor.json +23 -7
- package/assets/templates/settings.claude.json +12 -12
- package/package.json +1 -1
- package/src/cli/index.ts +16 -3
- package/src/cli/ledger.ts +329 -0
- package/src/hooks/LedgerCommit.ts +7 -21
- package/src/hooks/LedgerSnapshot.ts +7 -14
- package/src/hooks/LedgerUnapplied.ts +17 -15
- package/src/hooks/LoadContext.ts +4 -2
- package/src/hooks/lib/actor.ts +4 -4
- package/src/hooks/lib/agent.ts +91 -11
- package/src/hooks/lib/ledger-hook.ts +186 -17
- package/src/hooks/lib/ledger.ts +25 -5
- package/src/hooks/lib/sensitive-path.ts +117 -0
- package/src/hooks/lib/settings.ts +5 -0
- package/src/targets/lib.ts +17 -3
- package/src/targets/opencode/plugin.ts +22 -0
- package/src/tools/agent/project.ts +28 -3
- package/src/tools/ledger/query.ts +300 -0
|
@@ -6,8 +6,17 @@
|
|
|
6
6
|
* tool the post-tool half ignores leaves a snapshot nothing ever claims.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
10
|
+
import { isAbsolute, resolve } from "node:path";
|
|
9
11
|
import { normalizeToolUse } from "./agent";
|
|
10
|
-
import
|
|
12
|
+
import {
|
|
13
|
+
claimPending,
|
|
14
|
+
type LedgerEntry,
|
|
15
|
+
type LedgerOutcome,
|
|
16
|
+
reapStalePending,
|
|
17
|
+
recordAction,
|
|
18
|
+
savePending,
|
|
19
|
+
} from "./ledger";
|
|
11
20
|
|
|
12
21
|
/**
|
|
13
22
|
* Edits and writes, which carry their own target in the call. A shell command's
|
|
@@ -16,12 +25,39 @@ import type { LedgerOutcome } from "./ledger";
|
|
|
16
25
|
*
|
|
17
26
|
* Reads and searches are excluded because they are queries, not actions, and a
|
|
18
27
|
* ledger that logs them buries the changes among them.
|
|
28
|
+
*
|
|
29
|
+
* The four beyond edit and write are Copilot's own names for the same act — its
|
|
30
|
+
* runtime groups them as its file-editing tools, and it sends the command as
|
|
31
|
+
* the tool name rather than as an argument.
|
|
32
|
+
*/
|
|
33
|
+
const LEDGERED_TOOLS = new Set([
|
|
34
|
+
"edit",
|
|
35
|
+
"write",
|
|
36
|
+
"create",
|
|
37
|
+
"insert",
|
|
38
|
+
"str_replace",
|
|
39
|
+
"str_replace_editor",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Copilot's CLI also writes through a patch, and one call of that edits however
|
|
44
|
+
* many files the patch names. Its targets live in the patch body rather than in
|
|
45
|
+
* an argument, so it is filtered separately and read by patchedTargets.
|
|
46
|
+
*/
|
|
47
|
+
const PATCHING_TOOLS = new Set(["apply_patch", "applypatch"]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The editor tool that changes a file also reads one, under a command argument.
|
|
51
|
+
* A read recorded as an action would put queries back in a log of changes.
|
|
19
52
|
*/
|
|
20
|
-
const
|
|
53
|
+
const READING_COMMANDS = new Set(["view"]);
|
|
21
54
|
|
|
22
55
|
/** Agents disagree on the spelling; the value is the same file either way. */
|
|
23
56
|
const TARGET_KEYS = ["file_path", "filePath", "path"];
|
|
24
57
|
|
|
58
|
+
/** Every header in the V4A patch format that names a file the patch changes. */
|
|
59
|
+
const PATCHED_FILE_HEADER = /^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm;
|
|
60
|
+
|
|
25
61
|
export interface LedgeredCall {
|
|
26
62
|
toolUseId: string;
|
|
27
63
|
tool: string;
|
|
@@ -32,14 +68,104 @@ export interface LedgeredCall {
|
|
|
32
68
|
* The one reading of a payload both halves share. Asking the same question in
|
|
33
69
|
* two places is how they drift apart, and a drift here is silent: the pre-tool
|
|
34
70
|
* half parks a snapshot the post-tool half never comes to claim.
|
|
71
|
+
*
|
|
72
|
+
* A list because a patch call changes a set of files, not one.
|
|
35
73
|
*/
|
|
36
|
-
export function
|
|
74
|
+
export function ledgeredCalls(payload: Record<string, unknown>): LedgeredCall[] {
|
|
37
75
|
const toolUse = normalizeToolUse(payload);
|
|
38
|
-
|
|
39
|
-
|
|
76
|
+
if (!toolUse) return [];
|
|
77
|
+
|
|
78
|
+
const patched = patchedTargets(payload, toolUse.toolName);
|
|
79
|
+
if (patched.length > 0) return patchCalls(payload, toolUse.toolName, patched);
|
|
40
80
|
|
|
41
81
|
const target = ledgeredTarget(toolUse.toolName, toolUse.toolInput);
|
|
42
|
-
|
|
82
|
+
if (!target) return [];
|
|
83
|
+
|
|
84
|
+
const toolUseId = pairingKeyOf(payload, toolUse.toolName, target);
|
|
85
|
+
return toolUseId ? [{ toolUseId, tool: toolUse.toolName, target }] : [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A patch changes several files under one tool call, so the call's own id
|
|
90
|
+
* cannot key its snapshots — all of them would collide on it and only one would
|
|
91
|
+
* ever be claimed. Naming the target in the key is what keeps them apart.
|
|
92
|
+
*/
|
|
93
|
+
function patchCalls(
|
|
94
|
+
payload: Record<string, unknown>,
|
|
95
|
+
tool: string,
|
|
96
|
+
targets: string[]
|
|
97
|
+
): LedgeredCall[] {
|
|
98
|
+
const anchor = toolUseIdOf(payload) ?? sessionOf(payload);
|
|
99
|
+
if (!anchor) return [];
|
|
100
|
+
return targets.map((target) => ({
|
|
101
|
+
toolUseId: derivedPairingKey(anchor, tool, target),
|
|
102
|
+
tool,
|
|
103
|
+
target,
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The files a patch call changes, resolved against the directory the agent ran
|
|
109
|
+
* it in — a patch names them the way the agent typed them, which is relative.
|
|
110
|
+
*/
|
|
111
|
+
function patchedTargets(payload: Record<string, unknown>, toolName: string): string[] {
|
|
112
|
+
if (!PATCHING_TOOLS.has(toolName.toLowerCase())) return [];
|
|
113
|
+
|
|
114
|
+
const patch = patchBodyOf(payload);
|
|
115
|
+
if (!patch) return [];
|
|
116
|
+
|
|
117
|
+
const base = typeof payload.cwd === "string" ? payload.cwd : process.cwd();
|
|
118
|
+
return Array.from(patch.matchAll(PATCHED_FILE_HEADER), (match) =>
|
|
119
|
+
absoluteFrom(base, match[1].trim())
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Its arguments are the patch itself, rather than JSON naming a file. */
|
|
124
|
+
function patchBodyOf(payload: Record<string, unknown>): string | null {
|
|
125
|
+
const args = payload.toolArgs ?? payload.tool_input ?? payload.toolInput;
|
|
126
|
+
if (typeof args === "string") return args.length > 0 ? args : null;
|
|
127
|
+
return patchCommandOf(args);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Codex carries the patch under a command key instead of sending it as the arguments. */
|
|
131
|
+
function patchCommandOf(args: unknown): string | null {
|
|
132
|
+
if (typeof args !== "object" || args === null || Array.isArray(args)) return null;
|
|
133
|
+
const command = (args as Record<string, unknown>).command;
|
|
134
|
+
return typeof command === "string" && command.length > 0 ? command : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function absoluteFrom(base: string, path: string): string {
|
|
138
|
+
return isAbsolute(path) ? path : resolve(base, path);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Copilot's CLI publishes no id for a tool invocation, so its two halves are
|
|
143
|
+
* paired on what both do carry. Session, tool and target identify the call
|
|
144
|
+
* uniquely as long as it does not overlap another write to the same file in
|
|
145
|
+
* the same session, which sequential tool use cannot produce.
|
|
146
|
+
*/
|
|
147
|
+
function derivedPairingKey(anchor: string, tool: string, target: string): string {
|
|
148
|
+
const digest = new Bun.CryptoHasher("sha256")
|
|
149
|
+
.update([anchor, tool, target].join(" "), "utf-8")
|
|
150
|
+
.digest("hex");
|
|
151
|
+
return `derived-${digest.slice(0, 32)}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function sessionOf(payload: Record<string, unknown>): string | null {
|
|
155
|
+
const session = payload.sessionId ?? payload.session_id;
|
|
156
|
+
return typeof session === "string" && session.length > 0 ? session : null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function pairingKeyOf(
|
|
160
|
+
payload: Record<string, unknown>,
|
|
161
|
+
tool: string,
|
|
162
|
+
target: string
|
|
163
|
+
): string | null {
|
|
164
|
+
const explicit = toolUseIdOf(payload);
|
|
165
|
+
if (explicit) return explicit;
|
|
166
|
+
|
|
167
|
+
const session = sessionOf(payload);
|
|
168
|
+
return session ? derivedPairingKey(session, tool, target) : null;
|
|
43
169
|
}
|
|
44
170
|
|
|
45
171
|
/**
|
|
@@ -50,16 +176,28 @@ export function ledgeredCall(payload: Record<string, unknown>): LedgeredCall | n
|
|
|
50
176
|
* They carry the same fact under different keys, which is the whole reason this
|
|
51
177
|
* mapping is written down in one place rather than read twice.
|
|
52
178
|
*/
|
|
53
|
-
const UNAPPLIED_EVENTS: Record<string, { outcome: LedgerOutcome;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
179
|
+
const UNAPPLIED_EVENTS: Record<string, { outcome: LedgerOutcome; reasonKeys: string[] }> =
|
|
180
|
+
{
|
|
181
|
+
PostToolUseFailure: { outcome: "failed", reasonKeys: ["error"] },
|
|
182
|
+
PermissionDenied: { outcome: "denied", reasonKeys: ["reason"] },
|
|
183
|
+
postToolUseFailure: { outcome: "failed", reasonKeys: ["error_message", "error"] },
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
function deniedByFailureType(payload: Record<string, unknown>): boolean {
|
|
187
|
+
return payload.failure_type === "permission_denied";
|
|
188
|
+
}
|
|
57
189
|
|
|
58
190
|
export interface UnappliedVerdict {
|
|
59
191
|
outcome: LedgerOutcome;
|
|
60
192
|
reason?: string;
|
|
61
193
|
}
|
|
62
194
|
|
|
195
|
+
/** Copilot names the event nowhere in its payload, so its config says so on argv. */
|
|
196
|
+
function eventFromArgv(): string | undefined {
|
|
197
|
+
const flag = process.argv.find((a) => a.startsWith("--event="));
|
|
198
|
+
return flag?.slice("--event=".length) || undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
63
201
|
/**
|
|
64
202
|
* What became of a call, for the events that mean it did not land — or nothing
|
|
65
203
|
* when the payload is some other event, so one hook can be registered on both
|
|
@@ -68,18 +206,43 @@ export interface UnappliedVerdict {
|
|
|
68
206
|
export function unappliedVerdictOf(
|
|
69
207
|
payload: Record<string, unknown>
|
|
70
208
|
): UnappliedVerdict | null {
|
|
71
|
-
const event = payload.hook_event_name ?? payload.hookEventName;
|
|
209
|
+
const event = payload.hook_event_name ?? payload.hookEventName ?? eventFromArgv();
|
|
72
210
|
if (typeof event !== "string") return null;
|
|
73
211
|
|
|
74
212
|
const mapping = UNAPPLIED_EVENTS[event];
|
|
75
213
|
if (!mapping) return null;
|
|
76
214
|
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
215
|
+
const outcome = deniedByFailureType(payload) ? "denied" : mapping.outcome;
|
|
216
|
+
const reason = mapping.reasonKeys
|
|
217
|
+
.map((key) => payload[key])
|
|
218
|
+
.find((value): value is string => typeof value === "string" && value.length > 0);
|
|
219
|
+
return reason ? { outcome, reason } : { outcome };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function contentsOf(path: string): string | null {
|
|
223
|
+
return existsSync(path) ? readFileSync(path, "utf-8") : null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Park the target's current contents, the last moment they still exist. */
|
|
227
|
+
export function snapshotCall(call: LedgeredCall): void {
|
|
228
|
+
savePending({ ...call, before: contentsOf(call.target), ts: new Date().toISOString() });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Pair a parked before-state with the result, or nothing if none was parked. */
|
|
232
|
+
export function commitApplied(call: LedgeredCall): LedgerEntry | null {
|
|
233
|
+
const pending = claimPending(call.toolUseId);
|
|
234
|
+
if (!pending) return null;
|
|
235
|
+
|
|
236
|
+
const entry = recordAction({
|
|
237
|
+
tool: pending.tool,
|
|
238
|
+
target: pending.target,
|
|
239
|
+
outcome: "applied",
|
|
240
|
+
before: pending.before,
|
|
241
|
+
beforeState: pending.beforeState,
|
|
242
|
+
after: contentsOf(call.target),
|
|
243
|
+
});
|
|
244
|
+
reapStalePending();
|
|
245
|
+
return entry;
|
|
83
246
|
}
|
|
84
247
|
|
|
85
248
|
export function toolUseIdOf(payload: Record<string, unknown>): string | null {
|
|
@@ -90,6 +253,11 @@ export function toolUseIdOf(payload: Record<string, unknown>): string | null {
|
|
|
90
253
|
return null;
|
|
91
254
|
}
|
|
92
255
|
|
|
256
|
+
function readsRatherThanChanges(toolInput: Record<string, unknown>): boolean {
|
|
257
|
+
const command = toolInput.command;
|
|
258
|
+
return typeof command === "string" && READING_COMMANDS.has(command);
|
|
259
|
+
}
|
|
260
|
+
|
|
93
261
|
/**
|
|
94
262
|
* The absolute path this call will change, or nothing if the call is not one
|
|
95
263
|
* the ledger records.
|
|
@@ -99,6 +267,7 @@ export function ledgeredTarget(
|
|
|
99
267
|
toolInput: Record<string, unknown>
|
|
100
268
|
): string | null {
|
|
101
269
|
if (!LEDGERED_TOOLS.has(toolName.toLowerCase())) return null;
|
|
270
|
+
if (readsRatherThanChanges(toolInput)) return null;
|
|
102
271
|
for (const key of TARGET_KEYS) {
|
|
103
272
|
const value = toolInput[key];
|
|
104
273
|
if (typeof value === "string" && value.length > 0) return value;
|
package/src/hooks/lib/ledger.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { calcPatch } from "fast-myers-diff";
|
|
|
35
35
|
import { currentAttribution, type RecordAttribution } from "./actor";
|
|
36
36
|
import { encodeAnchor } from "./anchor";
|
|
37
37
|
import { ensureDir, paths } from "./paths";
|
|
38
|
+
import { isSensitivePath } from "./sensitive-path";
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* What became of the action.
|
|
@@ -82,6 +83,8 @@ export interface LedgerDelta {
|
|
|
82
83
|
hunks: LedgerHunk[];
|
|
83
84
|
/** Set when the change itself was too large to keep. Its absence means the hunks are complete. */
|
|
84
85
|
truncated?: boolean;
|
|
86
|
+
/** Set when the target is one whose contents the ledger never keeps. */
|
|
87
|
+
redacted?: boolean;
|
|
85
88
|
}
|
|
86
89
|
|
|
87
90
|
export interface LedgerEntry extends RecordAttribution {
|
|
@@ -111,8 +114,10 @@ export interface RecordActionInput {
|
|
|
111
114
|
/** Absolute path of the file the action targeted. */
|
|
112
115
|
target: string;
|
|
113
116
|
outcome: LedgerOutcome;
|
|
114
|
-
/** Prior content; null for a file creation. */
|
|
117
|
+
/** Prior content; null for a file creation, and withheld for a sensitive target. */
|
|
115
118
|
before: string | null;
|
|
119
|
+
/** Identity of the prior content, when the content itself was withheld. */
|
|
120
|
+
beforeState?: LedgerState;
|
|
116
121
|
/** Resulting content; null when nothing landed. */
|
|
117
122
|
after: string | null;
|
|
118
123
|
reason?: string;
|
|
@@ -156,6 +161,13 @@ function toLines(content: string | null): string[] {
|
|
|
156
161
|
* The change between two states, or nothing when there was no transition to
|
|
157
162
|
* describe. An action that did not land has no delta — see LedgerEntry.delta.
|
|
158
163
|
*/
|
|
164
|
+
const WITHHELD: LedgerDelta = { hunks: [], redacted: true };
|
|
165
|
+
|
|
166
|
+
function deltaFor(input: RecordActionInput): LedgerDelta | undefined {
|
|
167
|
+
if (isSensitivePath(input.target)) return WITHHELD;
|
|
168
|
+
return deltaOf(input.before, input.after);
|
|
169
|
+
}
|
|
170
|
+
|
|
159
171
|
function deltaOf(before: string | null, after: string | null): LedgerDelta | undefined {
|
|
160
172
|
if (after === null) return undefined;
|
|
161
173
|
|
|
@@ -180,7 +192,7 @@ function deltaOf(before: string | null, after: string | null): LedgerDelta | und
|
|
|
180
192
|
* side of the ledger — a stored change is only evidence if it can be replayed.
|
|
181
193
|
*/
|
|
182
194
|
export function applyDelta(before: string | null, delta: LedgerDelta): string | null {
|
|
183
|
-
if (delta.truncated) return null;
|
|
195
|
+
if (delta.truncated || delta.redacted) return null;
|
|
184
196
|
|
|
185
197
|
const lines = toLines(before);
|
|
186
198
|
const out: string[] = [];
|
|
@@ -230,7 +242,7 @@ function freeArchivePath(stamp: string): string {
|
|
|
230
242
|
* being asked of it.
|
|
231
243
|
*/
|
|
232
244
|
export function recordAction(input: RecordActionInput): LedgerEntry {
|
|
233
|
-
const delta =
|
|
245
|
+
const delta = deltaFor(input);
|
|
234
246
|
const entry: LedgerEntry = {
|
|
235
247
|
id: generateId(),
|
|
236
248
|
ts: new Date().toISOString(),
|
|
@@ -238,7 +250,7 @@ export function recordAction(input: RecordActionInput): LedgerEntry {
|
|
|
238
250
|
tool: input.tool,
|
|
239
251
|
target: encodeAnchor(input.target),
|
|
240
252
|
outcome: input.outcome,
|
|
241
|
-
before: stateOf(input.before),
|
|
253
|
+
before: input.beforeState ?? stateOf(input.before),
|
|
242
254
|
after: stateOf(input.after),
|
|
243
255
|
...(delta ? { delta } : {}),
|
|
244
256
|
...(input.reason ? { reason: input.reason } : {}),
|
|
@@ -262,6 +274,7 @@ export interface PendingSnapshot {
|
|
|
262
274
|
tool: string;
|
|
263
275
|
target: string;
|
|
264
276
|
before: string | null;
|
|
277
|
+
beforeState?: LedgerState;
|
|
265
278
|
ts: string;
|
|
266
279
|
}
|
|
267
280
|
|
|
@@ -281,8 +294,15 @@ function pendingPath(toolUseId: string): string {
|
|
|
281
294
|
return resolve(pendingDir(), `${toolUseId.replace(/[^A-Za-z0-9_-]/g, "")}.json`);
|
|
282
295
|
}
|
|
283
296
|
|
|
297
|
+
function withheldWhenSensitive(snapshot: PendingSnapshot): PendingSnapshot {
|
|
298
|
+
if (snapshot.before === null || !isSensitivePath(snapshot.target)) return snapshot;
|
|
299
|
+
const state = stateOf(snapshot.before);
|
|
300
|
+
return { ...snapshot, before: null, ...(state ? { beforeState: state } : {}) };
|
|
301
|
+
}
|
|
302
|
+
|
|
284
303
|
export function savePending(snapshot: PendingSnapshot): void {
|
|
285
|
-
|
|
304
|
+
const withheld = withheldWhenSensitive(snapshot);
|
|
305
|
+
writeFileSync(pendingPath(withheld.toolUseId), JSON.stringify(withheld), "utf-8");
|
|
286
306
|
}
|
|
287
307
|
|
|
288
308
|
/**
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Paths whose contents the ledger notes the change of but never keeps.
|
|
3
|
+
*
|
|
4
|
+
* The floor below is fixed in code rather than configured: a denylist a user can
|
|
5
|
+
* shrink is a suggestion, and settings may only add to this one. That direction
|
|
6
|
+
* also makes a malformed user pattern harmless — the worst it can do is redact
|
|
7
|
+
* something it did not need to.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { raw } from "./settings";
|
|
11
|
+
|
|
12
|
+
const ENV_TEMPLATE_SUFFIXES = [".sample", ".example", ".template", ".dist", ".defaults"];
|
|
13
|
+
|
|
14
|
+
/** Matched whole: `id_*` would catch `id_generator.ts`, `credentials*` a test file. */
|
|
15
|
+
const SECRET_FILENAMES = new Set([
|
|
16
|
+
".npmrc",
|
|
17
|
+
".netrc",
|
|
18
|
+
"_netrc",
|
|
19
|
+
".pgpass",
|
|
20
|
+
".htpasswd",
|
|
21
|
+
".envrc",
|
|
22
|
+
"credentials",
|
|
23
|
+
"id_rsa",
|
|
24
|
+
"id_dsa",
|
|
25
|
+
"id_ecdsa",
|
|
26
|
+
"id_ecdsa_sk",
|
|
27
|
+
"id_ed25519",
|
|
28
|
+
"id_ed25519_sk",
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
const SECRET_EXTENSIONS = [
|
|
32
|
+
".pem",
|
|
33
|
+
".key",
|
|
34
|
+
".p12",
|
|
35
|
+
".pfx",
|
|
36
|
+
".keystore",
|
|
37
|
+
".jks",
|
|
38
|
+
".asc",
|
|
39
|
+
".gpg",
|
|
40
|
+
".kdbx",
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const SECRET_DIRECTORIES = new Set([
|
|
44
|
+
".ssh",
|
|
45
|
+
".gnupg",
|
|
46
|
+
".aws",
|
|
47
|
+
".docker",
|
|
48
|
+
".kube",
|
|
49
|
+
".gcloud",
|
|
50
|
+
".azure",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const SECRET_DIRECTORY_PAIRS = [
|
|
54
|
+
[".config", "gh"],
|
|
55
|
+
[".config", "gcloud"],
|
|
56
|
+
[".local", "share/keyrings"],
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
function segmentsOf(path: string): string[] {
|
|
60
|
+
return path.split(/[/\\]/).filter(Boolean);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isLiveDotenv(name: string): boolean {
|
|
64
|
+
if (name !== ".env" && !name.startsWith(".env.")) return false;
|
|
65
|
+
return !ENV_TEMPLATE_SUFFIXES.some((suffix) => name.endsWith(suffix));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isSecretFilename(name: string): boolean {
|
|
69
|
+
return SECRET_FILENAMES.has(name);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function hasSecretExtension(name: string): boolean {
|
|
73
|
+
return SECRET_EXTENSIONS.some((ext) => name.toLowerCase().endsWith(ext));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function inSecretDirectory(dirs: string[]): boolean {
|
|
77
|
+
if (dirs.some((dir) => SECRET_DIRECTORIES.has(dir))) return true;
|
|
78
|
+
return SECRET_DIRECTORY_PAIRS.some(([parent, child]) =>
|
|
79
|
+
dirs.some(
|
|
80
|
+
(dir, i) =>
|
|
81
|
+
dir === parent &&
|
|
82
|
+
dirs
|
|
83
|
+
.slice(i + 1)
|
|
84
|
+
.join("/")
|
|
85
|
+
.startsWith(child)
|
|
86
|
+
)
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function userPatterns(): string[] {
|
|
91
|
+
const configured = raw().ledger?.redactPaths;
|
|
92
|
+
if (!Array.isArray(configured)) return [];
|
|
93
|
+
return configured.filter(
|
|
94
|
+
(pattern) => typeof pattern === "string" && pattern.length > 0
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function matchesUserPattern(path: string, name: string): boolean {
|
|
99
|
+
return userPatterns().some((pattern) => {
|
|
100
|
+
const glob = new Bun.Glob(pattern);
|
|
101
|
+
return glob.match(path) || glob.match(name);
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Should this file's contents be withheld from the ledger? */
|
|
106
|
+
export function isSensitivePath(path: string): boolean {
|
|
107
|
+
const segments = segmentsOf(path);
|
|
108
|
+
const name = segments.at(-1) ?? "";
|
|
109
|
+
const dirs = segments.slice(0, -1);
|
|
110
|
+
return (
|
|
111
|
+
inSecretDirectory(dirs) ||
|
|
112
|
+
isLiveDotenv(name) ||
|
|
113
|
+
isSecretFilename(name) ||
|
|
114
|
+
hasSecretExtension(name) ||
|
|
115
|
+
matchesUserPattern(path, name)
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -25,6 +25,11 @@ export interface PalSettingsData {
|
|
|
25
25
|
dynamicContext?: Record<string, boolean>;
|
|
26
26
|
/** Git co-author attribution opt-in. `decided` gates the one-time prompt. */
|
|
27
27
|
attribution?: { enabled?: boolean; decided?: boolean };
|
|
28
|
+
/**
|
|
29
|
+
* Action-ledger user extension. `redactPaths` adds to the built-in set of
|
|
30
|
+
* paths whose contents are never stored; it cannot shrink it.
|
|
31
|
+
*/
|
|
32
|
+
ledger?: { redactPaths?: string[] };
|
|
28
33
|
/** Contextual-steering user extension: personal rules + shipped rules to suppress by tag. */
|
|
29
34
|
steering?: {
|
|
30
35
|
disable?: string[];
|
package/src/targets/lib.ts
CHANGED
|
@@ -472,7 +472,21 @@ export function unmergeCursorHooks(
|
|
|
472
472
|
|
|
473
473
|
type CodexHookCommand = { type: string; command: string; timeout?: number };
|
|
474
474
|
type CodexHookGroup = { matcher?: string; hooks: CodexHookCommand[] };
|
|
475
|
-
type CodexHooks = {
|
|
475
|
+
type CodexHooks = {
|
|
476
|
+
hooks?: Record<string, CodexHookGroup[]>;
|
|
477
|
+
description?: string;
|
|
478
|
+
version?: unknown;
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Codex parses hooks.json strictly and accepts only `description` and `hooks`.
|
|
483
|
+
* A stale `version` makes it reject the whole file — every PAL hook silently
|
|
484
|
+
* stops — and merging preserves what it finds, so it must be dropped by name.
|
|
485
|
+
*/
|
|
486
|
+
function withoutRejectedFields(config: CodexHooks): CodexHooks {
|
|
487
|
+
const { version: _version, ...accepted } = config;
|
|
488
|
+
return accepted;
|
|
489
|
+
}
|
|
476
490
|
|
|
477
491
|
/**
|
|
478
492
|
* Normalize a PAL hook command for cross-path deduplication.
|
|
@@ -542,7 +556,7 @@ function stripPalHooks(
|
|
|
542
556
|
|
|
543
557
|
/** Merge PAL hooks into an existing Codex hooks.json. Deduplicates by canonical command path. */
|
|
544
558
|
export function mergeCodexHooks(existing: CodexHooks, template: CodexHooks): CodexHooks {
|
|
545
|
-
const result: CodexHooks =
|
|
559
|
+
const result: CodexHooks = withoutRejectedFields(existing);
|
|
546
560
|
if (!template.hooks) return result;
|
|
547
561
|
result.hooks ??= {};
|
|
548
562
|
|
|
@@ -561,7 +575,7 @@ export function unmergeCodexHooks(
|
|
|
561
575
|
existing: CodexHooks,
|
|
562
576
|
template: CodexHooks
|
|
563
577
|
): CodexHooks {
|
|
564
|
-
const result: CodexHooks =
|
|
578
|
+
const result: CodexHooks = withoutRejectedFields(existing);
|
|
565
579
|
if (!template.hooks || !result.hooks) return result;
|
|
566
580
|
|
|
567
581
|
stripPalHooks(result.hooks, collectPalCanonical(template));
|
|
@@ -106,6 +106,14 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
|
|
|
106
106
|
const { isPalSpawnedInference } =
|
|
107
107
|
await lib<typeof import("../../hooks/lib/spawn-guard")>("spawn-guard.ts");
|
|
108
108
|
|
|
109
|
+
const { commitApplied, ledgeredTarget, snapshotCall } =
|
|
110
|
+
await lib<typeof import("../../hooks/lib/ledger-hook")>("ledger-hook.ts");
|
|
111
|
+
|
|
112
|
+
const ledgeredOpencodeCall = (tool: string, callID: string, args: unknown) => {
|
|
113
|
+
const target = ledgeredTarget(tool, (args ?? {}) as Record<string, unknown>);
|
|
114
|
+
return target ? { toolUseId: callID, tool, target } : null;
|
|
115
|
+
};
|
|
116
|
+
|
|
109
117
|
return {
|
|
110
118
|
// --- Per-message: Inject dynamic system reminder ---
|
|
111
119
|
"experimental.chat.system.transform": async (_input, output) => {
|
|
@@ -213,6 +221,20 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
|
|
|
213
221
|
throw new Error(`PAL Security: ${fileReason}`);
|
|
214
222
|
}
|
|
215
223
|
}
|
|
224
|
+
|
|
225
|
+
const call = ledgeredOpencodeCall(toolName, _input.callID, output.args);
|
|
226
|
+
if (call) snapshotCall(call);
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
"tool.execute.after": async (
|
|
230
|
+
input: { tool: string; sessionID: string; callID: string; args: unknown },
|
|
231
|
+
_output: { title: string; output: string; metadata: unknown }
|
|
232
|
+
) => {
|
|
233
|
+
const call = ledgeredOpencodeCall(input.tool, input.callID, input.args);
|
|
234
|
+
if (!call) return;
|
|
235
|
+
|
|
236
|
+
const entry = commitApplied(call);
|
|
237
|
+
if (entry) logDebug("opencode:ledger", `recorded ${entry.id} ${entry.target}`);
|
|
216
238
|
},
|
|
217
239
|
|
|
218
240
|
// --- Inject PAL_DIR into shell environment ---
|
|
@@ -392,11 +392,36 @@ interface Isc {
|
|
|
392
392
|
status: IscStatus;
|
|
393
393
|
}
|
|
394
394
|
|
|
395
|
+
/**
|
|
396
|
+
* An ISC is one markdown line, so a newline in its text would end the record
|
|
397
|
+
* and strand every paragraph after it as unparseable debris. Backslashes are
|
|
398
|
+
* escaped first so that decoding a literal "\n" in a regex cannot be mistaken
|
|
399
|
+
* for the separator.
|
|
400
|
+
*/
|
|
401
|
+
function encodeIscText(text: string): string {
|
|
402
|
+
return text
|
|
403
|
+
.replaceAll("\\", "\\\\")
|
|
404
|
+
.replaceAll("\r\n", "\n")
|
|
405
|
+
.replaceAll("\r", "\n")
|
|
406
|
+
.replaceAll("\n", "\\n");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const ISC_UNESCAPE: Record<string, string> = { n: "\n", "\\": "\\" };
|
|
410
|
+
|
|
411
|
+
function decodeIscText(stored: string): string {
|
|
412
|
+
return stored.replaceAll(/\\(.)/g, (whole, ch) => ISC_UNESCAPE[ch] ?? whole);
|
|
413
|
+
}
|
|
414
|
+
|
|
395
415
|
function parseIscs(criteria: string): Isc[] {
|
|
396
416
|
const out: Isc[] = [];
|
|
397
417
|
for (const line of criteria.split("\n")) {
|
|
398
418
|
const m = new RegExp(/^-\s+\[( |x|~)\]\s+ISC-(\d+):\s+(.+)$/i).exec(line);
|
|
399
|
-
if (m)
|
|
419
|
+
if (m)
|
|
420
|
+
out.push({
|
|
421
|
+
id: Number(m[2]),
|
|
422
|
+
text: decodeIscText(m[3].trim()),
|
|
423
|
+
status: statusFromBox(m[1]),
|
|
424
|
+
});
|
|
400
425
|
}
|
|
401
426
|
return out;
|
|
402
427
|
}
|
|
@@ -471,7 +496,7 @@ function cmdAddIsc(args: string[]): void {
|
|
|
471
496
|
const p = requireProject(name);
|
|
472
497
|
const current = p.criteria ?? "";
|
|
473
498
|
const id = nextIscId(p);
|
|
474
|
-
const newLine = `- [ ] ISC-${id}: ${title}`;
|
|
499
|
+
const newLine = `- [ ] ISC-${id}: ${encodeIscText(title)}`;
|
|
475
500
|
p.criteria = current ? `${current.trimEnd()}\n${newLine}` : newLine;
|
|
476
501
|
p.updated = now();
|
|
477
502
|
writeProject(p);
|
|
@@ -628,7 +653,7 @@ function cmdEditIsc(args: string[]): void {
|
|
|
628
653
|
.split("\n")
|
|
629
654
|
.map((l) =>
|
|
630
655
|
new RegExp(String.raw`^-\s+\[[ x~]\]\s+ISC-${id}:`, "i").test(l)
|
|
631
|
-
? `- ${box} ISC-${id}: ${text}`
|
|
656
|
+
? `- ${box} ISC-${id}: ${encodeIscText(text)}`
|
|
632
657
|
: l
|
|
633
658
|
)
|
|
634
659
|
.join("\n");
|