portable-agent-layer 0.71.0 → 0.72.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/package.json +1 -1
- package/src/cli/migrate.ts +1 -1
- package/src/cli/skill.ts +1 -1
- package/src/hooks/CompactRecover.ts +28 -86
- package/src/hooks/LedgerUnapplied.ts +3 -28
- package/src/hooks/LoadContext.ts +33 -60
- package/src/hooks/SecurityValidator.ts +16 -109
- package/src/hooks/handlers/failure-principle.ts +19 -44
- package/src/hooks/handlers/session-intelligence.ts +13 -70
- package/src/hooks/lib/capture-store.ts +103 -0
- package/src/hooks/lib/compact-recall.ts +89 -0
- package/src/hooks/lib/failure-principle.ts +98 -0
- package/src/hooks/lib/ledger-hook.ts +35 -0
- package/src/hooks/lib/ledger.ts +48 -1
- package/src/hooks/lib/security-gate.ts +159 -0
- package/src/hooks/lib/session-context.ts +74 -0
- package/src/tools/agent/algorithm-reflect.ts +28 -97
- package/src/tools/agent/analyze.ts +19 -120
- package/src/tools/agent/handoff-note.ts +29 -77
- package/src/tools/agent/project.ts +13 -134
- package/src/tools/agent/relationship-note.ts +27 -46
- package/src/tools/agent/synthesize.ts +1 -1
- package/src/tools/agent/thread.ts +43 -123
- package/src/tools/control-room/data.ts +2 -2
- package/src/tools/control-room/matrix.ts +1 -1
- package/src/tools/control-room/ui/ledger.tsx +2 -1
- package/src/tools/ledger/view.ts +3 -0
- package/src/tools/lib/algorithm-reflect.ts +84 -0
- package/src/tools/lib/analyze-report.ts +120 -0
- package/src/tools/lib/handoff-note.ts +88 -0
- package/src/tools/lib/note-flags.ts +59 -0
- package/src/tools/lib/project-isc.ts +151 -0
- package/src/tools/lib/relationship-reflect.ts +402 -0
- package/src/tools/lib/self-model.ts +499 -0
- package/src/tools/lib/session-usage.ts +216 -0
- package/src/tools/lib/skill-doctor.ts +457 -0
- package/src/tools/lib/thread.ts +119 -0
- package/src/tools/lib/token-report.ts +173 -0
- package/src/tools/lib/transcript-usage.ts +42 -0
- package/src/tools/lib/usage-buckets.ts +329 -0
- package/src/tools/relationship-reflect.ts +48 -412
- package/src/tools/self-model.ts +76 -558
- package/src/tools/session-summary.ts +8 -215
- package/src/tools/skill-doctor.ts +9 -444
- package/src/tools/token-cost.ts +18 -428
package/package.json
CHANGED
package/src/cli/migrate.ts
CHANGED
|
@@ -26,7 +26,6 @@ import {
|
|
|
26
26
|
readProject,
|
|
27
27
|
writeProject,
|
|
28
28
|
} from "../hooks/lib/projects";
|
|
29
|
-
import { readThreads, type Thread, writeThreads } from "../tools/agent/thread";
|
|
30
29
|
import { appendSourceLog } from "../tools/knowledge/ingest";
|
|
31
30
|
import {
|
|
32
31
|
type Entity,
|
|
@@ -35,6 +34,7 @@ import {
|
|
|
35
34
|
save as knowledgeSave,
|
|
36
35
|
slugify,
|
|
37
36
|
} from "../tools/knowledge/lib";
|
|
37
|
+
import { readThreads, type Thread, writeThreads } from "../tools/lib/thread";
|
|
38
38
|
|
|
39
39
|
// ── Types ─────────────────────────────────────────────────────────
|
|
40
40
|
|
package/src/cli/skill.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { getActiveAgent } from "../hooks/lib/agent";
|
|
|
16
16
|
import { flagshipAuthorModel } from "../hooks/lib/models";
|
|
17
17
|
import { palHome } from "../hooks/lib/paths";
|
|
18
18
|
import { linkPersonalSkill, log } from "../targets/lib";
|
|
19
|
-
import { formatReport, formatSummary, lintSkill } from "../tools/skill-doctor";
|
|
19
|
+
import { formatReport, formatSummary, lintSkill } from "../tools/lib/skill-doctor";
|
|
20
20
|
|
|
21
21
|
/** Entry names under ~/.pal/skills/, sorted; dangling links included. */
|
|
22
22
|
function skillEntries(dir: string): string[] {
|
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hook: SessionStart
|
|
2
|
+
* Hook: SessionStart(compact) — re-injects the exchange that was in flight when
|
|
3
|
+
* the window filled, since the summary may have collapsed it.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* hook docs.
|
|
7
|
-
*
|
|
8
|
-
* Storage: ~/.pal/memory/state/last-exchange/{session_id}.json (with latest.json fallback).
|
|
5
|
+
* What to inject and what may then be deleted is in lib/compact-recall.ts, where
|
|
6
|
+
* a test can import it.
|
|
9
7
|
*/
|
|
10
8
|
|
|
11
|
-
import { existsSync } from "node:fs";
|
|
12
9
|
import { readFile, unlink } from "node:fs/promises";
|
|
13
|
-
import { resolve } from "node:path";
|
|
14
10
|
import { isCursor } from "./lib/agent";
|
|
11
|
+
import {
|
|
12
|
+
buildRecall,
|
|
13
|
+
findSavedExchange,
|
|
14
|
+
isConsumable,
|
|
15
|
+
type SavedExchange,
|
|
16
|
+
} from "./lib/compact-recall";
|
|
15
17
|
import { logDebug, logError } from "./lib/log";
|
|
16
|
-
import { paths } from "./lib/paths";
|
|
17
18
|
import { isPalSpawnedInference } from "./lib/spawn-guard";
|
|
18
19
|
import { readStdinJSON } from "./lib/stdin";
|
|
19
20
|
|
|
@@ -26,90 +27,31 @@ interface SessionStartInput {
|
|
|
26
27
|
source?: "startup" | "resume" | "clear" | "compact" | string;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
sessionId: string;
|
|
31
|
-
timestamp: string;
|
|
32
|
-
trigger: string | null;
|
|
33
|
-
customInstructions: string | null;
|
|
34
|
-
userMessage: string;
|
|
35
|
-
assistantMessage: string;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// Hook output cap is 10,000 chars per Claude Code docs; leave headroom for framing.
|
|
39
|
-
const MAX_OUTPUT = 9_000;
|
|
40
|
-
|
|
41
|
-
function truncate(s: string, max: number): string {
|
|
42
|
-
if (s.length <= max) return s;
|
|
43
|
-
return `${s.slice(0, max)}\n[... truncated ${s.length - max} chars]`;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const main = async () => {
|
|
30
|
+
try {
|
|
47
31
|
const input = await readStdinJSON<SessionStartInput>();
|
|
48
32
|
|
|
49
|
-
// The matcher gates this hook to "compact" sessions, but verify defensively in
|
|
50
|
-
// the matcher is misconfigured or the hook is invoked manually.
|
|
33
|
+
// The matcher gates this hook to "compact" sessions, but verify defensively in
|
|
34
|
+
// case the matcher is misconfigured or the hook is invoked manually.
|
|
51
35
|
if (input?.source && input.source !== "compact") {
|
|
52
36
|
logDebug("CompactRecover", `source=${input.source} — not compact, skipping`);
|
|
53
37
|
process.exit(0);
|
|
54
38
|
}
|
|
55
39
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
resolve(stateDir, "latest.json"),
|
|
62
|
-
].filter((p): p is string => p !== null);
|
|
63
|
-
|
|
64
|
-
const file = candidates.find((p) => existsSync(p));
|
|
65
|
-
if (!file) {
|
|
66
|
-
logDebug("CompactRecover", "No saved exchange found — silent no-op");
|
|
67
|
-
process.exit(0);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const saved = JSON.parse(await readFile(file, "utf-8")) as SavedExchange;
|
|
71
|
-
const userBudget = Math.floor(MAX_OUTPUT * 0.4);
|
|
72
|
-
const assistantBudget = MAX_OUTPUT - userBudget - 300; // reserve for framing
|
|
73
|
-
|
|
74
|
-
const out = [
|
|
75
|
-
"<system-reminder>",
|
|
76
|
-
"## Last exchange before compaction",
|
|
77
|
-
"_Restored verbatim from PAL state. The compaction summary may have collapsed this; the originals are below._",
|
|
78
|
-
"",
|
|
79
|
-
"**User:**",
|
|
80
|
-
truncate(saved.userMessage || "(no user message captured)", userBudget),
|
|
81
|
-
"",
|
|
82
|
-
"**Assistant:**",
|
|
83
|
-
truncate(
|
|
84
|
-
saved.assistantMessage || "(no assistant message captured)",
|
|
85
|
-
assistantBudget
|
|
86
|
-
),
|
|
87
|
-
"</system-reminder>",
|
|
88
|
-
].join("\n");
|
|
89
|
-
|
|
90
|
-
if (isCursor()) {
|
|
91
|
-
process.stdout.write(JSON.stringify({ additional_context: out }));
|
|
92
|
-
} else {
|
|
93
|
-
process.stdout.write(out);
|
|
94
|
-
}
|
|
95
|
-
logDebug("CompactRecover", `Re-injected ${out.length} chars from ${file}`);
|
|
96
|
-
|
|
97
|
-
// Consume-on-read: drop the session-keyed file after a successful injection so it
|
|
98
|
-
// doesn't sit on disk forever. latest.json is preserved as a safety fallback and
|
|
99
|
-
// gets overwritten on the next compaction.
|
|
100
|
-
const sessionFile = sessionId ? resolve(stateDir, `${sessionId}.json`) : null;
|
|
101
|
-
if (sessionFile && file === sessionFile) {
|
|
102
|
-
try {
|
|
103
|
-
await unlink(sessionFile);
|
|
104
|
-
} catch (err) {
|
|
105
|
-
logError("CompactRecover:cleanup", err);
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
} catch (err) {
|
|
109
|
-
logError("CompactRecover", err);
|
|
40
|
+
const sessionId = input?.session_id;
|
|
41
|
+
const file = findSavedExchange(sessionId);
|
|
42
|
+
if (!file) {
|
|
43
|
+
logDebug("CompactRecover", "No saved exchange found — silent no-op");
|
|
44
|
+
process.exit(0);
|
|
110
45
|
}
|
|
111
46
|
|
|
112
|
-
|
|
113
|
-
|
|
47
|
+
const saved = JSON.parse(await readFile(file, "utf-8")) as SavedExchange;
|
|
48
|
+
const out = buildRecall(saved);
|
|
49
|
+
process.stdout.write(isCursor() ? JSON.stringify({ additional_context: out }) : out);
|
|
50
|
+
logDebug("CompactRecover", `Re-injected ${out.length} chars from ${file}`);
|
|
51
|
+
|
|
52
|
+
if (isConsumable(file, sessionId)) await unlink(file);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
logError("CompactRecover", err);
|
|
55
|
+
}
|
|
114
56
|
|
|
115
|
-
|
|
57
|
+
process.exit(0);
|
|
@@ -20,27 +20,11 @@
|
|
|
20
20
|
* Silent and fail-open, for the same reason as the other halves.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
claimPending,
|
|
26
|
-
type PendingSnapshot,
|
|
27
|
-
reapStalePending,
|
|
28
|
-
recordAction,
|
|
29
|
-
} from "./lib/ledger";
|
|
30
|
-
import { ledgeredCalls, unappliedVerdictOf } from "./lib/ledger-hook";
|
|
23
|
+
import { reapStalePending } from "./lib/ledger";
|
|
24
|
+
import { commitUnapplied, ledgeredCalls, unappliedVerdictOf } from "./lib/ledger-hook";
|
|
31
25
|
import { logDebug } from "./lib/log";
|
|
32
26
|
import { readStdinJSON } from "./lib/stdin";
|
|
33
27
|
|
|
34
|
-
/**
|
|
35
|
-
* The snapshot is the trustworthy source, but its absence is recoverable here
|
|
36
|
-
* in a way it never is after a successful edit: nothing landed, so whatever is
|
|
37
|
-
* on disk now is still the before-state.
|
|
38
|
-
*/
|
|
39
|
-
function beforeState(pending: PendingSnapshot | null, target: string): string | null {
|
|
40
|
-
if (pending) return pending.before;
|
|
41
|
-
return existsSync(target) ? readFileSync(target, "utf-8") : null;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
28
|
try {
|
|
45
29
|
const input = await readStdinJSON<Record<string, unknown>>();
|
|
46
30
|
if (!input) process.exit(0);
|
|
@@ -50,16 +34,7 @@ try {
|
|
|
50
34
|
if (!verdict) process.exit(0);
|
|
51
35
|
|
|
52
36
|
for (const call of calls) {
|
|
53
|
-
const entry =
|
|
54
|
-
tool: call.tool,
|
|
55
|
-
target: call.target,
|
|
56
|
-
outcome: verdict.outcome,
|
|
57
|
-
before: beforeState(claimPending(call.toolUseId), call.target),
|
|
58
|
-
// Nothing landed. That is what this event means, and it is the difference
|
|
59
|
-
// between this entry and an applied one.
|
|
60
|
-
after: null,
|
|
61
|
-
reason: verdict.reason,
|
|
62
|
-
});
|
|
37
|
+
const entry = commitUnapplied(call, verdict);
|
|
63
38
|
logDebug("LedgerUnapplied", `recorded ${entry.id} ${entry.outcome} ${entry.target}`);
|
|
64
39
|
}
|
|
65
40
|
|
package/src/hooks/LoadContext.ts
CHANGED
|
@@ -1,97 +1,70 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Hook: SessionStart — Injects dynamic context + regenerates AGENTS.md if stale.
|
|
3
3
|
*
|
|
4
|
-
* Static context (TELOS, setup prompt) is loaded natively from AGENTS.md /
|
|
5
|
-
* This hook injects dynamic context only: wisdom principles,
|
|
6
|
-
* learning digest, signal trends, failure patterns,
|
|
4
|
+
* Static context (TELOS, setup prompt) is loaded natively from AGENTS.md /
|
|
5
|
+
* CLAUDE.md. This hook injects dynamic context only: wisdom principles,
|
|
6
|
+
* relationship notes, learning digest, signal trends, failure patterns, work state.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* ~/.copilot/instructions/ is read only by the VS Code extension. The merged
|
|
10
|
-
* context goes to both so either surface picks it up.
|
|
8
|
+
* Which agent gets what, in which envelope, is in lib/session-context.ts.
|
|
11
9
|
*/
|
|
12
10
|
|
|
13
11
|
import { mkdirSync, writeFileSync } from "node:fs";
|
|
14
12
|
import { resolve } from "node:path";
|
|
15
|
-
import { getActiveAgent
|
|
13
|
+
import { getActiveAgent } from "./lib/agent";
|
|
16
14
|
import { buildClaudeMd, regenerateIfNeeded } from "./lib/claude-md";
|
|
17
15
|
import { type AgentTarget, buildSystemReminder } from "./lib/context";
|
|
18
16
|
import { logContextSnapshot, logDebug, logError } from "./lib/log";
|
|
19
17
|
import { platform } from "./lib/paths";
|
|
18
|
+
import {
|
|
19
|
+
contextEnvelope,
|
|
20
|
+
copilotInstructions,
|
|
21
|
+
isSubagentSession,
|
|
22
|
+
needsAgentsMd,
|
|
23
|
+
} from "./lib/session-context";
|
|
20
24
|
import { isPalSpawnedInference } from "./lib/spawn-guard";
|
|
21
25
|
|
|
22
26
|
// Recursion guard — when this process is a PAL-spawned inference subprocess,
|
|
23
27
|
// skip all context loading so we don't trigger another inference call.
|
|
24
28
|
if (isPalSpawnedInference()) process.exit(0);
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
const isSubagent =
|
|
28
|
-
process.env.CLAUDE_PROJECT_DIR?.includes("/.claude/Agents/") ||
|
|
29
|
-
process.env.CLAUDE_AGENT_TYPE !== undefined;
|
|
30
|
-
|
|
31
|
-
if (isSubagent) {
|
|
30
|
+
if (isSubagentSession(process.env)) {
|
|
32
31
|
logDebug("LoadContext", "Subagent session — skipping context loading");
|
|
33
32
|
process.exit(0);
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
// --- Regenerate CLAUDE.md if telos or setup changed ---
|
|
37
35
|
try {
|
|
38
|
-
|
|
39
|
-
if (rebuilt) logDebug("LoadContext", "AGENTS.md regenerated");
|
|
36
|
+
if (regenerateIfNeeded()) logDebug("LoadContext", "AGENTS.md regenerated");
|
|
40
37
|
} catch (err) {
|
|
41
38
|
logError("LoadContext:regenerate", err);
|
|
42
39
|
}
|
|
43
40
|
|
|
44
|
-
// --- Context to stdout (or file for Copilot) ---
|
|
45
41
|
try {
|
|
46
|
-
// Determine agent target — controls which sections are skipped (loaded natively instead).
|
|
47
42
|
const active = getActiveAgent();
|
|
48
|
-
|
|
43
|
+
// The reminder is built for one of three targets; every other agent reads the
|
|
44
|
+
// same shape Claude Code does.
|
|
45
|
+
const target: AgentTarget =
|
|
49
46
|
active === "copilot" || active === "cursor" ? active : "claude";
|
|
50
|
-
const reminder = buildSystemReminder({ agent });
|
|
47
|
+
const reminder = buildSystemReminder({ agent: target });
|
|
51
48
|
if (!reminder) process.exit(0);
|
|
52
49
|
logContextSnapshot(reminder);
|
|
53
50
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
);
|
|
67
|
-
process.stdout.write(JSON.stringify({ additionalContext: context }));
|
|
68
|
-
}
|
|
69
|
-
logDebug(
|
|
70
|
-
"LoadContext",
|
|
71
|
-
`Copilot session instructions written: ${context.length} chars`
|
|
72
|
-
);
|
|
73
|
-
} else if (isCursor()) {
|
|
74
|
-
// Cursor: semi-static in ~/.cursor/rules/pal-context.mdc; inject AGENTS.md + dynamic here
|
|
75
|
-
const agentsMd = buildClaudeMd();
|
|
76
|
-
const context = [agentsMd, reminder].filter(Boolean).join("\n\n");
|
|
77
|
-
process.stdout.write(JSON.stringify({ additional_context: context }));
|
|
78
|
-
logDebug("LoadContext", `Reminder injected: ${reminder.length} chars`);
|
|
79
|
-
} else if (isCodex()) {
|
|
80
|
-
// Codex: AGENTS.md already loaded via symlink; inject only dynamic context
|
|
81
|
-
process.stdout.write(
|
|
82
|
-
JSON.stringify({
|
|
83
|
-
hookSpecificOutput: {
|
|
84
|
-
hookEventName: "SessionStart",
|
|
85
|
-
additionalContext: reminder,
|
|
86
|
-
},
|
|
87
|
-
})
|
|
88
|
-
);
|
|
89
|
-
logDebug("LoadContext", `Codex reminder injected: ${reminder.length} chars`);
|
|
90
|
-
} else {
|
|
91
|
-
// Claude Code (and opencode, which uses the plugin path not this hook): raw text
|
|
92
|
-
console.log(reminder);
|
|
93
|
-
logDebug("LoadContext", `Reminder injected: ${reminder.length} chars`);
|
|
51
|
+
const envelope = contextEnvelope(
|
|
52
|
+
active,
|
|
53
|
+
reminder,
|
|
54
|
+
needsAgentsMd(active) ? buildClaudeMd() : ""
|
|
55
|
+
);
|
|
56
|
+
if (!envelope) process.exit(0);
|
|
57
|
+
|
|
58
|
+
if (envelope.file) {
|
|
59
|
+
const dir = resolve(platform.copilotDir(), "instructions");
|
|
60
|
+
mkdirSync(dir, { recursive: true });
|
|
61
|
+
const path = resolve(dir, "pal-session.instructions.md");
|
|
62
|
+
writeFileSync(path, copilotInstructions(envelope.file), "utf-8");
|
|
94
63
|
}
|
|
64
|
+
|
|
65
|
+
if (envelope.kind === "text") console.log(envelope.payload);
|
|
66
|
+
else process.stdout.write(envelope.payload);
|
|
67
|
+
logDebug("LoadContext", `Reminder injected: ${reminder.length} chars`);
|
|
95
68
|
} catch (err) {
|
|
96
69
|
logError("LoadContext:reminder", err);
|
|
97
70
|
}
|
|
@@ -3,125 +3,32 @@
|
|
|
3
3
|
* Emits the current agent's deny response to block, or exits silently to allow.
|
|
4
4
|
*
|
|
5
5
|
* Fail-open design: if anything goes wrong, the command is allowed through.
|
|
6
|
+
*
|
|
7
|
+
* The decision itself is in lib/security-gate.ts, where a test can import it.
|
|
6
8
|
*/
|
|
7
9
|
|
|
8
|
-
import { blockResponse
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
10
|
+
import { blockResponse } from "./lib/agent";
|
|
11
|
+
import { recordBlocked } from "./lib/ledger";
|
|
12
|
+
import { logError } from "./lib/log";
|
|
13
|
+
import { decideRefusal, type SecurityInput } from "./lib/security-gate";
|
|
11
14
|
import { readStdinJSON } from "./lib/stdin";
|
|
12
15
|
|
|
13
|
-
// beforeShellExecution shape (Cursor only) — flat, no tool-name wrapper
|
|
14
|
-
interface ShellExecInput {
|
|
15
|
-
command: string;
|
|
16
|
-
sandbox?: boolean;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
type SecurityInput = Record<string, unknown> | ShellExecInput;
|
|
20
|
-
|
|
21
|
-
function isShellExec(input: SecurityInput): input is ShellExecInput {
|
|
22
|
-
return !("tool_name" in input) && !("toolName" in input) && "command" in input;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// A name this list misses is a command this hook waves through, so both sets mirror
|
|
26
|
-
// the tool names VS Code's own Copilot build ships in its shell and edit tool sets.
|
|
27
|
-
const SHELL_TOOLS = [
|
|
28
|
-
"bash",
|
|
29
|
-
"shell",
|
|
30
|
-
"powershell",
|
|
31
|
-
"local_shell",
|
|
32
|
-
"runinterminal",
|
|
33
|
-
"run_in_terminal",
|
|
34
|
-
"terminal",
|
|
35
|
-
"execute_command",
|
|
36
|
-
];
|
|
37
|
-
|
|
38
|
-
const FILE_WRITE_TOOLS = [
|
|
39
|
-
"write",
|
|
40
|
-
"edit",
|
|
41
|
-
"multiedit",
|
|
42
|
-
"write_file",
|
|
43
|
-
"apply_patch",
|
|
44
|
-
"applypatch",
|
|
45
|
-
"create",
|
|
46
|
-
"create_file",
|
|
47
|
-
"createfile",
|
|
48
|
-
"str_replace",
|
|
49
|
-
"str_replace_editor",
|
|
50
|
-
"insert",
|
|
51
|
-
"insert_edit_into_file",
|
|
52
|
-
"replace_string_in_file",
|
|
53
|
-
"multi_replace_string_in_file",
|
|
54
|
-
"replacestring",
|
|
55
|
-
"edit_notebook_file",
|
|
56
|
-
"notebookedit",
|
|
57
|
-
];
|
|
58
|
-
|
|
59
|
-
/** First of `keys` present as a non-empty string — agents disagree on argument spelling. */
|
|
60
|
-
function firstStringArg(
|
|
61
|
-
args: Record<string, unknown>,
|
|
62
|
-
keys: string[]
|
|
63
|
-
): string | undefined {
|
|
64
|
-
for (const key of keys) {
|
|
65
|
-
const value = args[key];
|
|
66
|
-
if (typeof value === "string" && value.length > 0) return value;
|
|
67
|
-
}
|
|
68
|
-
return undefined;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** Tool names that run a shell command, across every agent's naming. */
|
|
72
|
-
function runsShellCommand(toolName: string): boolean {
|
|
73
|
-
return SHELL_TOOLS.includes(toolName.toLowerCase());
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Tool names that write to a file, across every agent's naming. */
|
|
77
|
-
function writesFile(toolName: string): boolean {
|
|
78
|
-
return FILE_WRITE_TOOLS.includes(toolName.toLowerCase());
|
|
79
|
-
}
|
|
80
|
-
|
|
81
16
|
try {
|
|
82
17
|
const input = await readStdinJSON<SecurityInput>();
|
|
83
18
|
if (!input) process.exit(0);
|
|
84
19
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const reason = checkBashCommand(input.command);
|
|
88
|
-
if (reason) {
|
|
89
|
-
process.stdout.write(blockResponse(`Blocked: ${reason}`));
|
|
90
|
-
}
|
|
91
|
-
process.exit(0);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
const toolUse = normalizeToolUse(input);
|
|
95
|
-
if (!toolUse) process.exit(0);
|
|
20
|
+
const refusal = decideRefusal(input, process.cwd());
|
|
21
|
+
if (!refusal) process.exit(0);
|
|
96
22
|
|
|
97
|
-
|
|
98
|
-
// unrecognized one shows up here instead of silently skipping the check.
|
|
99
|
-
logDebug(
|
|
100
|
-
"SecurityValidator",
|
|
101
|
-
`toolName=${toolUse.toolName} args=${Object.keys(toolUse.toolInput).join(",")}`
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
const command = firstStringArg(toolUse.toolInput, ["command", "commandLine", "script"]);
|
|
105
|
-
if (runsShellCommand(toolUse.toolName) && typeof command === "string") {
|
|
106
|
-
const reason = checkBashCommand(command);
|
|
107
|
-
const verdict = reason ? `BLOCK(${reason})` : "ALLOW";
|
|
108
|
-
// "No output" from a downstream tool is indistinguishable between "denied,
|
|
109
|
-
// never ran" and "ran, produced nothing" — logging the verdict here, next
|
|
110
|
-
// to the literal command, is what actually tells the two apart.
|
|
111
|
-
logDebug("SecurityValidator", `bashVerdict=${verdict} command=${command}`);
|
|
112
|
-
if (reason) {
|
|
113
|
-
process.stdout.write(blockResponse(`Blocked: ${reason}`, toolUse.hookEventName));
|
|
114
|
-
process.exit(0);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
23
|
+
process.stdout.write(blockResponse(refusal.message, refusal.hookEventName));
|
|
117
24
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
25
|
+
// After the deny has gone out, in its own try/catch: this hook is fail-open,
|
|
26
|
+
// and a ledger that threw on its way to recording a block would turn the
|
|
27
|
+
// block into an allow.
|
|
28
|
+
try {
|
|
29
|
+
recordBlocked(refusal);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
logError("SecurityValidator:ledger", err);
|
|
125
32
|
}
|
|
126
33
|
} catch {
|
|
127
34
|
// Fail open
|
|
@@ -11,24 +11,22 @@
|
|
|
11
11
|
|
|
12
12
|
import { existsSync } from "node:fs";
|
|
13
13
|
import { readFile, unlink } from "node:fs/promises";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
mergeInferredPrinciple,
|
|
16
|
+
needsInference,
|
|
17
|
+
type PendingFailure,
|
|
18
|
+
principleRequest,
|
|
19
|
+
recentExchange,
|
|
20
|
+
} from "../lib/failure-principle";
|
|
15
21
|
import { captureFailure } from "./failure";
|
|
16
22
|
|
|
17
|
-
interface PendingFailure {
|
|
18
|
-
rating: number;
|
|
19
|
-
context: string;
|
|
20
|
-
detailedContext?: string;
|
|
21
|
-
principle?: string;
|
|
22
|
-
responsePreview?: string;
|
|
23
|
-
userPreview?: string;
|
|
24
|
-
cwd?: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
23
|
/**
|
|
28
24
|
* Inference the principle (if missing) and persist the failure record.
|
|
29
25
|
* Reads pending data + transcript from the provided tmp paths and unlinks them.
|
|
26
|
+
*
|
|
27
|
+
* @lintignore exercised directly by test/failure-principle.test.ts
|
|
30
28
|
*/
|
|
31
|
-
async function processFailurePrinciple(
|
|
29
|
+
export async function processFailurePrinciple(
|
|
32
30
|
pendingPath: string,
|
|
33
31
|
transcriptPath: string
|
|
34
32
|
): Promise<void> {
|
|
@@ -47,42 +45,19 @@ async function processFailurePrinciple(
|
|
|
47
45
|
logDebug("failure-principle", `processing rating=${pending.rating}`);
|
|
48
46
|
|
|
49
47
|
let { principle, detailedContext } = pending;
|
|
50
|
-
if (
|
|
48
|
+
if (needsInference(pending)) {
|
|
51
49
|
try {
|
|
52
50
|
const { inference } = await import("../lib/inference");
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
.join("\n\n");
|
|
58
|
-
|
|
59
|
-
const result = await inference({
|
|
60
|
-
system: `Analyze this failed AI interaction (rated ${pending.rating}/10). Return JSON: {"principle": "<verb-first actionable rule, 10-20 words — write a full sentence, not a fragment>", "detailed_context": "<root cause and what to do differently, 50-150 words>"}.`,
|
|
61
|
-
user: `User feedback: ${pending.context}\n\nConversation:\n${recent}`,
|
|
62
|
-
maxTokens: 400,
|
|
63
|
-
timeout: 90000,
|
|
64
|
-
jsonSchema: {
|
|
65
|
-
type: "object" as const,
|
|
66
|
-
properties: {
|
|
67
|
-
principle: { type: "string" as const },
|
|
68
|
-
detailed_context: { type: "string" as const },
|
|
69
|
-
},
|
|
70
|
-
required: ["principle", "detailed_context"],
|
|
71
|
-
additionalProperties: false,
|
|
72
|
-
},
|
|
73
|
-
caller: "failure-principle",
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
if (result.success && result.output) {
|
|
77
|
-
const parsed = JSON.parse(result.output) as {
|
|
78
|
-
principle?: string;
|
|
79
|
-
detailed_context?: string;
|
|
80
|
-
};
|
|
81
|
-
principle = parsed.principle || undefined;
|
|
82
|
-
detailedContext ??= parsed.detailed_context || undefined;
|
|
83
|
-
} else {
|
|
51
|
+
const result = await inference(
|
|
52
|
+
principleRequest(pending, recentExchange(transcript))
|
|
53
|
+
);
|
|
54
|
+
if (!result.success || !result.output) {
|
|
84
55
|
logError("failure-principle", `inference failed (no output)`);
|
|
85
56
|
}
|
|
57
|
+
({ principle, detailedContext } = mergeInferredPrinciple(
|
|
58
|
+
pending,
|
|
59
|
+
result.output ?? null
|
|
60
|
+
));
|
|
86
61
|
} catch (err) {
|
|
87
62
|
logError("failure-principle:inference", err);
|
|
88
63
|
}
|