@causyne/agent 1.10.12 → 1.10.17
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/LICENSE +1 -1
- package/docs/ARCHITECTURE.md +136 -7
- package/docs/SECURITY_REVIEW.md +13 -1
- package/docs/THIRD_PARTY_NOTICES.md +43 -10
- package/package.json +11 -4
- package/src/agentLoop.js +161 -93
- package/src/agentProfiles.js +135 -0
- package/src/agentProfiles.ts +143 -0
- package/src/approvalGrants.js +17 -35
- package/src/approvalGrants.ts +54 -0
- package/src/auditLog.d.ts +3 -0
- package/src/benchmarks/planCache.js +159 -0
- package/src/browser/camofoxRuntime.js +93 -0
- package/src/browser/camofoxRuntime.ts +112 -0
- package/src/browser/causyneBrowser.js +495 -0
- package/src/browser/causyneBrowser.ts +594 -0
- package/src/browser/searchProviders.js +156 -0
- package/src/browser/searchProviders.ts +156 -0
- package/src/browser/snapshotWindow.js +28 -0
- package/src/browser/snapshotWindow.ts +40 -0
- package/src/browser/urlPolicy.js +89 -0
- package/src/browser/urlPolicy.ts +89 -0
- package/src/checkpoints.js +19 -6
- package/src/commands.js +120 -8
- package/src/config.d.ts +2 -0
- package/src/cortex/autoCondenser.js +1 -1
- package/src/hooks.js +150 -0
- package/src/hooks.ts +193 -0
- package/src/mcp/mcpClient.js +520 -132
- package/src/mcp/mcpClient.ts +584 -0
- package/src/mcp/mcpRegistry.js +399 -185
- package/src/mcp/mcpRegistry.ts +451 -0
- package/src/planCache.js +529 -225
- package/src/planCache.ts +840 -0
- package/src/plugins/pluginManager.js +460 -0
- package/src/plugins/pluginRunner.js +50 -0
- package/src/plugins/pluginSkills.js +84 -0
- package/src/plugins/pluginSkills.ts +106 -0
- package/src/provisioning/capabilityProvisioner.d.ts +10 -0
- package/src/provisioning/capabilityProvisioner.js +5 -0
- package/src/provisioning/capabilityTools.js +2 -1
- package/src/sandbox.js +53 -2
- package/src/sessionJournal.js +471 -0
- package/src/sessionJournal.ts +496 -0
- package/src/sessionStore.js +3 -279
- package/src/skillsManager.js +18 -0
- package/src/subagentJournal.js +215 -0
- package/src/subagentJournal.ts +254 -0
- package/src/taskShape.js +179 -198
- package/src/taskShape.ts +259 -0
- package/src/toolPacks.js +6 -3
- package/src/tools/browserAutomator.js +45 -241
- package/src/tools/browserAutomator.ts +57 -0
- package/src/tools/deferredMcpSearch.js +2 -2
- package/src/tools/manageLocalAiDependencies.js +1 -1
- package/src/tools/pluginManager.js +61 -0
- package/src/tools/registry.js +47 -53
- package/src/tools/subagentOrchestrator.js +73 -12
- package/src/tools/toolForge.js +47 -95
- package/src/tools/webFetch.js +183 -68
- package/src/tools/webFetch.ts +185 -0
- package/src/tools/webSearch.js +50 -75
- package/src/tools/webSearch.ts +56 -0
- package/src/types/contracts.js +8 -0
- package/src/types/contracts.ts +263 -0
- package/src/utils/paths.d.ts +3 -0
- package/src/version.d.ts +1 -0
- package/src/mcp/jsonRpc.js +0 -165
package/src/agentLoop.js
CHANGED
|
@@ -7,6 +7,7 @@ import { TaskBuffer } from './taskBuffer.js';
|
|
|
7
7
|
import { TOOL_DEFINITIONS, getDynamicTools, resolveTool, canonicalToolName } from './tools/registry.js';
|
|
8
8
|
import { Sandbox } from './sandbox.js';
|
|
9
9
|
import { auditLog } from './auditLog.js';
|
|
10
|
+
import { hasMatchingHook, runConfiguredHooks } from './hooks.js';
|
|
10
11
|
import { loadConfig } from './config.js';
|
|
11
12
|
import { isDevEmulator } from './devMode.js';
|
|
12
13
|
import { TokenTracker } from './tokenTracker.js';
|
|
@@ -14,7 +15,7 @@ import { TokenTracker } from './tokenTracker.js';
|
|
|
14
15
|
import { getMemoryContextString } from './memory.js';
|
|
15
16
|
import { MemoryManager } from './memoryManager.js';
|
|
16
17
|
import { cortexDb } from './cortex/vectorDb.js';
|
|
17
|
-
import { condenseAndSave } from './cortex/autoCondenser.js';
|
|
18
|
+
import { buildStructuralSummary, condenseAndSave } from './cortex/autoCondenser.js';
|
|
18
19
|
import { analyzeTask, buildTaskBrief, packsFromShape } from './taskShape.js';
|
|
19
20
|
import { lookupPlan, recordPlan, recordMiss, renderPlanHint, toStep } from './planCache.js';
|
|
20
21
|
import { routedConfig } from './modelRouter.js';
|
|
@@ -343,6 +344,11 @@ export class AgentLoop {
|
|
|
343
344
|
this.circuitBreakerUnlocked = false;
|
|
344
345
|
this.stopRequested = false;
|
|
345
346
|
this.pendingApprovals = new Map(); // Maps toolCallId -> resolve function
|
|
347
|
+
// A delegated subagent can tighten these two ceilings. They are applied
|
|
348
|
+
// after every config refresh so run() cannot accidentally replace a
|
|
349
|
+
// worker's declared bounds with the parent tier defaults.
|
|
350
|
+
this.maxStepsCeiling = null;
|
|
351
|
+
this.taskTokenLimit = null;
|
|
346
352
|
|
|
347
353
|
// Limits based on tier/budget & emulator fast bypass
|
|
348
354
|
this.updateTierLimits();
|
|
@@ -497,18 +503,9 @@ export class AgentLoop {
|
|
|
497
503
|
// once per task rather than once per step.
|
|
498
504
|
let planHint = '';
|
|
499
505
|
try {
|
|
500
|
-
|
|
501
|
-
prompt: this.currentTaskPrompt || this.userPrompt,
|
|
502
|
-
archetype: this.taskShape ? this.taskShape.archetype : ''
|
|
503
|
-
});
|
|
504
|
-
if (match) {
|
|
505
|
-
planHint = renderPlanHint(match);
|
|
506
|
-
this._offeredPlanId = match.template.id;
|
|
507
|
-
this._planSimilarity = match.similarity;
|
|
508
|
-
this.debugLog(`plan cache hit ${(match.similarity * 100).toFixed(0)}% (${match.template.steps.length} steps)`);
|
|
509
|
-
}
|
|
506
|
+
planHint = renderPlanHint(this._planMatch);
|
|
510
507
|
} catch (e) {
|
|
511
|
-
this.debugLog(`plan cache
|
|
508
|
+
this.debugLog(`plan cache hint skipped: ${e.message}`);
|
|
512
509
|
}
|
|
513
510
|
|
|
514
511
|
return { lessonsLearned, designContext, antiRepeatDirective, universalSkillsContext, planHint };
|
|
@@ -601,6 +598,10 @@ export class AgentLoop {
|
|
|
601
598
|
for (const toolCall of toolCalls) {
|
|
602
599
|
const functionName = canonicalToolName(toolCall.function.name);
|
|
603
600
|
if (!AgentLoop.READ_ONLY_TOOLS.has(functionName)) continue;
|
|
601
|
+
// Prefetch begins before the serial dispatch path. If this tool has a
|
|
602
|
+
// PreToolUse hook, running it here would create a temporal pre-trust
|
|
603
|
+
// window where the read happens before the hook can deny it.
|
|
604
|
+
if (hasMatchingHook(this.config.hooks, 'PreToolUse', functionName)) continue;
|
|
604
605
|
|
|
605
606
|
let args;
|
|
606
607
|
try {
|
|
@@ -723,6 +724,17 @@ export class AgentLoop {
|
|
|
723
724
|
} else {
|
|
724
725
|
this.maxSteps = 300;
|
|
725
726
|
}
|
|
727
|
+
if (Number.isInteger(this.maxStepsCeiling) && this.maxStepsCeiling > 0) {
|
|
728
|
+
this.maxSteps = Math.min(this.maxSteps, this.maxStepsCeiling);
|
|
729
|
+
}
|
|
730
|
+
if (Number.isInteger(this.taskTokenLimit) && this.taskTokenLimit > 0 && this.tokenTracker) {
|
|
731
|
+
this.tokenTracker.budget = {
|
|
732
|
+
...this.tokenTracker.budget,
|
|
733
|
+
enabled: true,
|
|
734
|
+
hardStop: true,
|
|
735
|
+
maxTokensPerTask: this.taskTokenLimit
|
|
736
|
+
};
|
|
737
|
+
}
|
|
726
738
|
this.maxCircuitBreaker = this.maxSteps;
|
|
727
739
|
}
|
|
728
740
|
|
|
@@ -800,6 +812,7 @@ export class AgentLoop {
|
|
|
800
812
|
this._planSteps = [];
|
|
801
813
|
this._offeredPlanId = null;
|
|
802
814
|
this._planSimilarity = 0;
|
|
815
|
+
this._planMatch = null;
|
|
803
816
|
this._lessonSimilarity = 0;
|
|
804
817
|
this._lastToolResult = null;
|
|
805
818
|
this._paradigmShiftPending = false;
|
|
@@ -816,6 +829,36 @@ export class AgentLoop {
|
|
|
816
829
|
this.unlockPack(pack);
|
|
817
830
|
}
|
|
818
831
|
|
|
832
|
+
// Reuse a known route before the first model call. Exact keyword-set
|
|
833
|
+
// matches are always eligible; fuzzy reuse requires at least medium task
|
|
834
|
+
// classification confidence because APC's threshold study shows that
|
|
835
|
+
// aggressive fuzzy hits trade accuracy for speed.
|
|
836
|
+
try {
|
|
837
|
+
this._planMatch = lookupPlan({
|
|
838
|
+
prompt: this.userPrompt,
|
|
839
|
+
archetype: this.taskShape ? this.taskShape.archetype : '',
|
|
840
|
+
allowFuzzy: this.taskShape?.confidence !== 'low'
|
|
841
|
+
});
|
|
842
|
+
if (this._planMatch) {
|
|
843
|
+
this._offeredPlanId = this._planMatch.template.id;
|
|
844
|
+
this._planSimilarity = this._planMatch.similarity;
|
|
845
|
+
for (const step of this._planMatch.template.steps || []) {
|
|
846
|
+
if (!step.ok || !resolveTool(step.tool)) continue;
|
|
847
|
+
this.unlockedTools.add(step.tool);
|
|
848
|
+
const pack = packForTool(step.tool);
|
|
849
|
+
if (pack) this.unlockPack(pack);
|
|
850
|
+
}
|
|
851
|
+
this.debugLog(
|
|
852
|
+
`plan cache ${this._planMatch.matchType || 'fuzzy'} hit `
|
|
853
|
+
+ `${(this._planMatch.similarity * 100).toFixed(0)}% `
|
|
854
|
+
+ `(${this._planMatch.template.steps.length} steps, `
|
|
855
|
+
+ `${this._planMatch.telemetry?.lookupMs?.toFixed(2) || '0.00'} ms)`
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
} catch (e) {
|
|
859
|
+
this.debugLog(`plan cache lookup skipped: ${e.message}`);
|
|
860
|
+
}
|
|
861
|
+
|
|
819
862
|
this.debugLog(`task shape: ${this.taskShape.archetype} (${this.taskShape.confidence} confidence)`);
|
|
820
863
|
}
|
|
821
864
|
|
|
@@ -972,6 +1015,13 @@ export class AgentLoop {
|
|
|
972
1015
|
});
|
|
973
1016
|
if (compaction.totalReclaimed > 0) {
|
|
974
1017
|
this.debugLog(`progressive compaction reclaimed ${(compaction.totalReclaimed / 1024).toFixed(1)} KB [${compaction.stagesFired.join(', ')}]`);
|
|
1018
|
+
auditLog.log({
|
|
1019
|
+
event: 'context_compaction', taskId: this.taskId,
|
|
1020
|
+
kind: 'progressive', stages: compaction.stagesFired,
|
|
1021
|
+
reclaimedChars: compaction.totalReclaimed,
|
|
1022
|
+
finalChars: compaction.finalChars,
|
|
1023
|
+
limitChars: MAX_CHAR_POOL
|
|
1024
|
+
});
|
|
975
1025
|
}
|
|
976
1026
|
|
|
977
1027
|
const { window: slidingWindow, splitIndex } = buildWindow(this.messages, MAX_CHAR_POOL);
|
|
@@ -996,9 +1046,17 @@ export class AgentLoop {
|
|
|
996
1046
|
if (splitIndex >= 1) {
|
|
997
1047
|
const dropped = this.messages.slice(1, splitIndex + 1);
|
|
998
1048
|
if (dropped.length > 0) {
|
|
999
|
-
const
|
|
1049
|
+
const compactSummary = buildStructuralSummary(dropped) || describeDroppedMessages(dropped);
|
|
1050
|
+
const removed = compactInPlace(this.messages, splitIndex, compactSummary);
|
|
1000
1051
|
this._lastCompactionIndex = null;
|
|
1001
1052
|
this.debugLog(`compacted ${removed} messages out of the live context`);
|
|
1053
|
+
auditLog.log({
|
|
1054
|
+
event: 'context_compaction', taskId: this.taskId,
|
|
1055
|
+
kind: 'summary', removedMessages: removed,
|
|
1056
|
+
retainedSummaryChars: compactSummary.length,
|
|
1057
|
+
limitChars: MAX_CHAR_POOL
|
|
1058
|
+
});
|
|
1059
|
+
this.notice(`Context compacted: ${removed} earlier messages condensed into a reviewable summary.`);
|
|
1002
1060
|
|
|
1003
1061
|
// V5 Cortex tiered condensation. Tier 1 (structural, free) always
|
|
1004
1062
|
// runs so the Digital Brain keeps learning on every provider. Tier 2
|
|
@@ -1029,6 +1087,8 @@ export class AgentLoop {
|
|
|
1029
1087
|
unlockedTools: this.unlockedTools,
|
|
1030
1088
|
unlockOrder: this.unlockOrder,
|
|
1031
1089
|
grantAllTools: this.config.grantAllTools === true,
|
|
1090
|
+
allowedTools: this.sandbox.allowedTools,
|
|
1091
|
+
deniedTools: this.config.deniedTools,
|
|
1032
1092
|
residentOnly
|
|
1033
1093
|
});
|
|
1034
1094
|
// A delegated subagent runs with a declared toolScope. Enforcing it
|
|
@@ -1477,18 +1537,24 @@ export class AgentLoop {
|
|
|
1477
1537
|
// if it was declined, fall through to the path that needs nothing.
|
|
1478
1538
|
let visionReady = false;
|
|
1479
1539
|
let visionDeclined = false;
|
|
1540
|
+
let browserReady = false;
|
|
1541
|
+
let browserDeclined = false;
|
|
1480
1542
|
try {
|
|
1481
1543
|
const { groupStatus, isGroupDeclined } = await import('./provisioning/capabilityProvisioner.js');
|
|
1482
1544
|
visionReady = groupStatus('vision').state === 'ready';
|
|
1483
1545
|
visionDeclined = isGroupDeclined('vision');
|
|
1546
|
+
browserReady = groupStatus('browser').state === 'ready';
|
|
1547
|
+
browserDeclined = isGroupDeclined('browser');
|
|
1484
1548
|
} catch (e) { /* treat as not ready */ }
|
|
1485
1549
|
let webGuardMsg = '';
|
|
1486
1550
|
|
|
1487
|
-
if (
|
|
1488
|
-
webGuardMsg = `[ANTI-SLOP CHECK: You are building a Web/UI project and must
|
|
1551
|
+
if (!browserReady && !browserDeclined) {
|
|
1552
|
+
webGuardMsg = `[ANTI-SLOP CHECK: You are building a Web/UI project and must inspect it before finishing, but the dedicated browser capability is not enabled yet. Call \`manage_local_dependencies\` with action "grant_capability" and group "browser" to request the Causyne Camoufox runtime. If the user declines, read the final compiled HTML/CSS instead. This check fires once.]`;
|
|
1553
|
+
} else if (hasVision && !visionReady && !visionDeclined) {
|
|
1554
|
+
webGuardMsg = `[ANTI-SLOP CHECK: The browser is available, but desktop screen capture is not enabled. Call \`manage_local_dependencies\` with action "grant_capability" and group "vision" to request it, or use \`browser_act\` snapshots and its workspace-scoped screenshot action. This check fires once.]`;
|
|
1489
1555
|
} else if (hasVision && visionReady && !this.webVerificationState.screenshotTaken) {
|
|
1490
1556
|
webGuardMsg = `[ANTI-SLOP CHECK: You are building a Web/UI project but attempted to finish without VISUALLY verifying your work. Use \`screenshot\` or \`vision_inspect_ui\` to look at the UI in a browser. A human looks at their work to avoid slop. This check fires once — if there is genuinely nothing visual to inspect, say so plainly and finish.]`;
|
|
1491
|
-
} else if ((!hasVision || visionDeclined) && !this.webVerificationState.browserActUsed && !this.webVerificationState.screenshotTaken) {
|
|
1557
|
+
} else if ((!hasVision || visionDeclined) && browserReady && !this.webVerificationState.browserActUsed && !this.webVerificationState.screenshotTaken) {
|
|
1492
1558
|
webGuardMsg = `[TRANSCENDENCE CHECK: You are building a Web/UI project. As a vision-disabled model you cannot screenshot, but you MUST use \`browser_act\` to inspect the DOM, or read the final compiled HTML/CSS, to verify semantic correctness before finishing. This check fires once.]`;
|
|
1493
1559
|
}
|
|
1494
1560
|
|
|
@@ -1699,58 +1765,10 @@ export class AgentLoop {
|
|
|
1699
1765
|
|
|
1700
1766
|
this.consecutiveBlockedActions = 0; // Reset counter on allowed action
|
|
1701
1767
|
|
|
1702
|
-
//
|
|
1703
|
-
//
|
|
1704
|
-
// old alias meant this consent gate never fired and multi-GB model
|
|
1705
|
-
// downloads ran unannounced.
|
|
1706
|
-
const isHeavyAiDownload = functionName === 'manage_local_dependencies' &&
|
|
1707
|
-
['pipInst', 'fetchBin', 'fetchMod'].includes(args.action);
|
|
1708
|
-
// A standing approval from "Allow always" lifts the gate for this
|
|
1709
|
-
// tool until the process restarts. It is checked HERE rather than
|
|
1710
|
-
|
|
1711
|
-
// asking and then auto-answering would still interrupt the user,
|
|
1712
|
-
// which is the entire thing they were trying to stop.
|
|
1713
|
-
//
|
|
1714
|
-
// Nothing else is bypassed: the sandbox has already run above, so a
|
|
1715
|
-
// FORBIDDEN command is still refused and a path outside the workspace
|
|
1716
|
-
// is still blocked. This waives the QUESTION, not the guard.
|
|
1717
|
-
const standingApproval = isAlwaysAllowed(functionName);
|
|
1718
|
-
if (standingApproval) {
|
|
1719
|
-
auditLog.log({
|
|
1720
|
-
event: 'approval_auto_granted', taskId: this.taskId,
|
|
1721
|
-
tool: functionName, risk: validation.risk, reason: 'allow_always'
|
|
1722
|
-
});
|
|
1723
|
-
}
|
|
1724
|
-
|
|
1725
|
-
if (!standingApproval && (
|
|
1726
|
-
validation.risk === 'extreme' ||
|
|
1727
|
-
isHeavyAiDownload ||
|
|
1728
|
-
(this.config.autonomyMode !== 'C' && validation.risk === 'dangerous') ||
|
|
1729
|
-
this.config.autonomyMode === 'A')) {
|
|
1730
|
-
|
|
1731
|
-
// Wait for local approval
|
|
1732
|
-
this.emitEvent({ type: 'approval_request', toolCallId: toolCall.id, tool: functionName, args, risk: validation.risk });
|
|
1733
|
-
|
|
1734
|
-
const approved = await this.waitForApproval(toolCall.id);
|
|
1735
|
-
|
|
1736
|
-
if (!approved) {
|
|
1737
|
-
this.messages.push({
|
|
1738
|
-
role: 'tool',
|
|
1739
|
-
tool_call_id: toolCall.id,
|
|
1740
|
-
name: functionName,
|
|
1741
|
-
content: JSON.stringify({ success: false, error: 'User denied this action' })
|
|
1742
|
-
});
|
|
1743
|
-
continue;
|
|
1744
|
-
}
|
|
1745
|
-
}
|
|
1746
|
-
|
|
1747
|
-
// Execute
|
|
1768
|
+
// Resolve and validate before asking the user for approval. Unknown or
|
|
1769
|
+
// malformed calls should self-correct without creating approval fatigue.
|
|
1748
1770
|
const tool = resolveTool(functionName);
|
|
1749
|
-
|
|
1750
|
-
// GUARD: Prevent crash if LLM hallucinates a non-existent tool name
|
|
1751
1771
|
if (!tool || typeof tool.execute !== 'function') {
|
|
1752
|
-
// Only advertise what is currently in context. Listing all 69 names
|
|
1753
|
-
// here re-bloated the very context progressive disclosure protects.
|
|
1754
1772
|
this.messages.push({
|
|
1755
1773
|
role: 'tool',
|
|
1756
1774
|
tool_call_id: toolCall.id,
|
|
@@ -1763,10 +1781,6 @@ export class AgentLoop {
|
|
|
1763
1781
|
continue;
|
|
1764
1782
|
}
|
|
1765
1783
|
|
|
1766
|
-
// AUTO-UNLOCK RECOVERY: the model named a real tool whose pack is not
|
|
1767
|
-
// loaded yet (it remembered it from an earlier task, or guessed well).
|
|
1768
|
-
// Unlock the pack and run it rather than dead-ending — capability is
|
|
1769
|
-
// deferred, never removed.
|
|
1770
1784
|
if (!isCoreTool(functionName) && !this.unlockedTools.has(functionName)) {
|
|
1771
1785
|
const pack = packForTool(functionName);
|
|
1772
1786
|
if (this.unlockPack(pack)) {
|
|
@@ -1775,26 +1789,29 @@ export class AgentLoop {
|
|
|
1775
1789
|
this.unlockedTools.add(functionName);
|
|
1776
1790
|
}
|
|
1777
1791
|
|
|
1778
|
-
// V5: Tool Call Argument Validation Layer
|
|
1779
1792
|
const validationErrors = this.validateToolArgs(functionName, args);
|
|
1780
1793
|
if (validationErrors) {
|
|
1781
1794
|
this.messages.push({
|
|
1782
1795
|
role: 'tool',
|
|
1783
1796
|
tool_call_id: toolCall.id,
|
|
1784
1797
|
name: functionName,
|
|
1785
|
-
content: JSON.stringify({
|
|
1786
|
-
success: false,
|
|
1787
|
-
error: `Invalid arguments: ${validationErrors}. Please fix and retry.`
|
|
1798
|
+
content: JSON.stringify({
|
|
1799
|
+
success: false,
|
|
1800
|
+
error: `Invalid arguments: ${validationErrors}. Please fix and retry.`
|
|
1788
1801
|
})
|
|
1789
1802
|
});
|
|
1790
1803
|
continue;
|
|
1791
1804
|
}
|
|
1792
1805
|
|
|
1793
|
-
//
|
|
1794
|
-
//
|
|
1795
|
-
//
|
|
1806
|
+
// A policy hook runs after the built-in sandbox but before any user
|
|
1807
|
+
// approval prompt, so a deterministic denial never asks the user to
|
|
1808
|
+
// approve work that will be rejected anyway.
|
|
1796
1809
|
const preHook = await this.runPreToolUse(functionName, args, toolCall.id);
|
|
1797
1810
|
if (preHook && preHook.denied) {
|
|
1811
|
+
auditLog.log({
|
|
1812
|
+
event: 'hook_denied', taskId: this.taskId, hookEvent: 'PreToolUse',
|
|
1813
|
+
tool: functionName, reason: preHook.reason || 'hook denied'
|
|
1814
|
+
});
|
|
1798
1815
|
this.messages.push({
|
|
1799
1816
|
role: 'tool',
|
|
1800
1817
|
tool_call_id: toolCall.id,
|
|
@@ -1808,6 +1825,51 @@ export class AgentLoop {
|
|
|
1808
1825
|
continue;
|
|
1809
1826
|
}
|
|
1810
1827
|
|
|
1828
|
+
// Approval flow for dangerous actions (Mode A/B) or ALWAYS for heavy AI downloads
|
|
1829
|
+
// The schema name is manage_local_dependencies. Comparing against the
|
|
1830
|
+
// old alias meant this consent gate never fired and multi-GB model
|
|
1831
|
+
// downloads ran unannounced.
|
|
1832
|
+
const isHeavyAiDownload = functionName === 'manage_local_dependencies' &&
|
|
1833
|
+
['pipInst', 'fetchBin', 'fetchMod'].includes(args.action);
|
|
1834
|
+
// A standing approval from "Allow always" lifts the gate for this
|
|
1835
|
+
// tool until the process restarts. It is checked HERE rather than
|
|
1836
|
+
|
|
1837
|
+
// asking and then auto-answering would still interrupt the user,
|
|
1838
|
+
// which is the entire thing they were trying to stop.
|
|
1839
|
+
//
|
|
1840
|
+
// Nothing else is bypassed: the sandbox has already run above, so a
|
|
1841
|
+
// FORBIDDEN command is still refused and a path outside the workspace
|
|
1842
|
+
// is still blocked. This waives the QUESTION, not the guard.
|
|
1843
|
+
const standingApproval = isAlwaysAllowed(functionName);
|
|
1844
|
+
if (standingApproval) {
|
|
1845
|
+
auditLog.log({
|
|
1846
|
+
event: 'approval_auto_granted', taskId: this.taskId,
|
|
1847
|
+
tool: functionName, risk: validation.risk, reason: 'allow_always'
|
|
1848
|
+
});
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
if (!standingApproval && (
|
|
1852
|
+
validation.risk === 'extreme' ||
|
|
1853
|
+
isHeavyAiDownload ||
|
|
1854
|
+
(this.config.autonomyMode !== 'C' && validation.risk === 'dangerous') ||
|
|
1855
|
+
this.config.autonomyMode === 'A')) {
|
|
1856
|
+
|
|
1857
|
+
// Wait for local approval
|
|
1858
|
+
this.emitEvent({ type: 'approval_request', toolCallId: toolCall.id, tool: functionName, args, risk: validation.risk });
|
|
1859
|
+
|
|
1860
|
+
const approved = await this.waitForApproval(toolCall.id);
|
|
1861
|
+
|
|
1862
|
+
if (!approved) {
|
|
1863
|
+
this.messages.push({
|
|
1864
|
+
role: 'tool',
|
|
1865
|
+
tool_call_id: toolCall.id,
|
|
1866
|
+
name: functionName,
|
|
1867
|
+
content: JSON.stringify({ success: false, error: 'User denied this action' })
|
|
1868
|
+
});
|
|
1869
|
+
continue;
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1811
1873
|
// P2: snapshot the files this call is about to change, before it runs.
|
|
1812
1874
|
// Only the mutating fs tools match; everything else returns immediately.
|
|
1813
1875
|
// Fail-open by contract — this can never block or fail a tool call.
|
|
@@ -2393,26 +2455,32 @@ export class AgentLoop {
|
|
|
2393
2455
|
return hints[errorType] || hints['unknown_error'];
|
|
2394
2456
|
}
|
|
2395
2457
|
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
* plug into.
|
|
2407
|
-
*/
|
|
2408
|
-
async runPreToolUse(_toolName, _args, _toolCallId) {
|
|
2409
|
-
return { denied: false, messages: [] };
|
|
2458
|
+
async runPreToolUse(toolName, args, toolCallId) {
|
|
2459
|
+
return runConfiguredHooks({
|
|
2460
|
+
hooks: this.config?.hooks,
|
|
2461
|
+
event: 'PreToolUse',
|
|
2462
|
+
toolName,
|
|
2463
|
+
args,
|
|
2464
|
+
taskId: this.taskId,
|
|
2465
|
+
toolCallId,
|
|
2466
|
+
workspacePaths: this.sandbox?.workspacePaths
|
|
2467
|
+
});
|
|
2410
2468
|
}
|
|
2411
2469
|
|
|
2412
|
-
async runPostToolUse(
|
|
2413
|
-
return {
|
|
2470
|
+
async runPostToolUse(toolName, args, result) {
|
|
2471
|
+
return runConfiguredHooks({
|
|
2472
|
+
hooks: this.config?.hooks,
|
|
2473
|
+
event: 'PostToolUse',
|
|
2474
|
+
toolName,
|
|
2475
|
+
args,
|
|
2476
|
+
result,
|
|
2477
|
+
taskId: this.taskId,
|
|
2478
|
+
workspacePaths: this.sandbox?.workspacePaths
|
|
2479
|
+
});
|
|
2414
2480
|
}
|
|
2415
2481
|
|
|
2482
|
+
// V5: Validate tool call arguments before execution
|
|
2483
|
+
|
|
2416
2484
|
validateToolArgs(toolName, args) {
|
|
2417
2485
|
// Check for common LLM mistakes
|
|
2418
2486
|
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { causyneHome } from './utils/paths.js';
|
|
4
|
+
const PROFILE_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
5
|
+
const TOOL_NAME = /^[a-zA-Z][a-zA-Z0-9_.-]{0,127}$/;
|
|
6
|
+
const MAX_PROFILES = 64;
|
|
7
|
+
const MAX_PROFILE_BYTES = 24 * 1024;
|
|
8
|
+
const MAX_INSTRUCTIONS = 16 * 1024;
|
|
9
|
+
const MAX_TOOLS = 64;
|
|
10
|
+
export function agentProfilesRoot() {
|
|
11
|
+
return path.join(causyneHome(), 'agents');
|
|
12
|
+
}
|
|
13
|
+
function cleanLine(value, maximum) {
|
|
14
|
+
return String(value ?? '')
|
|
15
|
+
// eslint-disable-next-line no-control-regex
|
|
16
|
+
.replace(/[\x00-\x1f\x7f]/g, ' ')
|
|
17
|
+
.replace(/\s+/g, ' ')
|
|
18
|
+
.trim()
|
|
19
|
+
.slice(0, maximum);
|
|
20
|
+
}
|
|
21
|
+
function cleanInstructions(value) {
|
|
22
|
+
return String(value ?? '')
|
|
23
|
+
// Preserve newlines and tabs while excluding prompt-control bytes.
|
|
24
|
+
// eslint-disable-next-line no-control-regex
|
|
25
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
|
26
|
+
.trim();
|
|
27
|
+
}
|
|
28
|
+
function boundedInteger(value, fallback, minimum, maximum) {
|
|
29
|
+
if (value === undefined)
|
|
30
|
+
return fallback;
|
|
31
|
+
if (!Number.isInteger(value))
|
|
32
|
+
return null;
|
|
33
|
+
return Math.max(minimum, Math.min(Number(value), maximum));
|
|
34
|
+
}
|
|
35
|
+
function parseProfile(file, expectedName) {
|
|
36
|
+
try {
|
|
37
|
+
const stat = fs.lstatSync(file);
|
|
38
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
39
|
+
return { ok: false, error: 'profile must be a regular file' };
|
|
40
|
+
if (stat.size > MAX_PROFILE_BYTES)
|
|
41
|
+
return { ok: false, error: `profile exceeds ${MAX_PROFILE_BYTES / 1024} KB` };
|
|
42
|
+
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
43
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
44
|
+
return { ok: false, error: 'profile must be a JSON object' };
|
|
45
|
+
}
|
|
46
|
+
const value = raw;
|
|
47
|
+
if (value.schemaVersion !== 1)
|
|
48
|
+
return { ok: false, error: 'schemaVersion must be 1' };
|
|
49
|
+
if (value.name !== expectedName || !PROFILE_ID.test(expectedName)) {
|
|
50
|
+
return { ok: false, error: 'profile name must match its lowercase file name' };
|
|
51
|
+
}
|
|
52
|
+
const description = cleanLine(value.description, 400);
|
|
53
|
+
if (!description)
|
|
54
|
+
return { ok: false, error: 'description is required' };
|
|
55
|
+
const instructions = cleanInstructions(value.instructions);
|
|
56
|
+
if (!instructions)
|
|
57
|
+
return { ok: false, error: 'instructions are required' };
|
|
58
|
+
if (Buffer.byteLength(instructions, 'utf8') > MAX_INSTRUCTIONS) {
|
|
59
|
+
return { ok: false, error: `instructions exceed ${MAX_INSTRUCTIONS / 1024} KB` };
|
|
60
|
+
}
|
|
61
|
+
if (!Array.isArray(value.toolScope) || value.toolScope.length === 0 || value.toolScope.length > MAX_TOOLS) {
|
|
62
|
+
return { ok: false, error: `toolScope must contain 1-${MAX_TOOLS} tool names` };
|
|
63
|
+
}
|
|
64
|
+
const toolScope = [];
|
|
65
|
+
for (const rawTool of value.toolScope) {
|
|
66
|
+
if (typeof rawTool !== 'string' || !TOOL_NAME.test(rawTool)) {
|
|
67
|
+
return { ok: false, error: `invalid tool name ${JSON.stringify(rawTool)}` };
|
|
68
|
+
}
|
|
69
|
+
const normalized = rawTool.replace(/\./g, '_');
|
|
70
|
+
if (!toolScope.includes(normalized))
|
|
71
|
+
toolScope.push(normalized);
|
|
72
|
+
}
|
|
73
|
+
const maxSteps = boundedInteger(value.maxSteps, 25, 1, 100);
|
|
74
|
+
if (maxSteps === null)
|
|
75
|
+
return { ok: false, error: 'maxSteps must be an integer' };
|
|
76
|
+
const maxTokens = boundedInteger(value.maxTokens, 50_000, 1_000, 200_000);
|
|
77
|
+
if (maxTokens === null)
|
|
78
|
+
return { ok: false, error: 'maxTokens must be an integer' };
|
|
79
|
+
return {
|
|
80
|
+
ok: true,
|
|
81
|
+
profile: {
|
|
82
|
+
schemaVersion: 1,
|
|
83
|
+
name: expectedName,
|
|
84
|
+
description,
|
|
85
|
+
instructions,
|
|
86
|
+
toolScope,
|
|
87
|
+
maxSteps,
|
|
88
|
+
maxTokens,
|
|
89
|
+
path: file
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
95
|
+
return { ok: false, error: message };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export function loadAgentProfile(name) {
|
|
99
|
+
const id = String(name ?? '');
|
|
100
|
+
if (!PROFILE_ID.test(id))
|
|
101
|
+
return { ok: false, error: 'invalid profile name' };
|
|
102
|
+
return parseProfile(path.join(agentProfilesRoot(), `${id}.json`), id);
|
|
103
|
+
}
|
|
104
|
+
export function listAgentProfiles() {
|
|
105
|
+
const profiles = [];
|
|
106
|
+
const problems = [];
|
|
107
|
+
const root = agentProfilesRoot();
|
|
108
|
+
try {
|
|
109
|
+
if (!fs.existsSync(root))
|
|
110
|
+
return { profiles, problems };
|
|
111
|
+
const entries = fs.readdirSync(root, { withFileTypes: true })
|
|
112
|
+
.filter((entry) => entry.name.endsWith('.json') && !entry.name.startsWith('.'))
|
|
113
|
+
.slice(0, MAX_PROFILES + 1);
|
|
114
|
+
if (entries.length > MAX_PROFILES)
|
|
115
|
+
problems.push(`only the first ${MAX_PROFILES} profiles were inspected`);
|
|
116
|
+
for (const entry of entries.slice(0, MAX_PROFILES)) {
|
|
117
|
+
const name = entry.name.slice(0, -5);
|
|
118
|
+
if (!entry.isFile() || !PROFILE_ID.test(name)) {
|
|
119
|
+
problems.push(`${entry.name}: invalid profile file`);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const loaded = parseProfile(path.join(root, entry.name), name);
|
|
123
|
+
if (loaded.ok)
|
|
124
|
+
profiles.push(loaded.profile);
|
|
125
|
+
else
|
|
126
|
+
problems.push(`${entry.name}: ${loaded.error}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
131
|
+
problems.push(`could not read profiles: ${message}`);
|
|
132
|
+
}
|
|
133
|
+
return { profiles, problems };
|
|
134
|
+
}
|
|
135
|
+
export { MAX_PROFILES, MAX_PROFILE_BYTES, MAX_INSTRUCTIONS, MAX_TOOLS };
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { causyneHome } from './utils/paths.js';
|
|
4
|
+
|
|
5
|
+
const PROFILE_ID = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
6
|
+
const TOOL_NAME = /^[a-zA-Z][a-zA-Z0-9_.-]{0,127}$/;
|
|
7
|
+
const MAX_PROFILES = 64;
|
|
8
|
+
const MAX_PROFILE_BYTES = 24 * 1024;
|
|
9
|
+
const MAX_INSTRUCTIONS = 16 * 1024;
|
|
10
|
+
const MAX_TOOLS = 64;
|
|
11
|
+
|
|
12
|
+
export interface AgentProfile {
|
|
13
|
+
schemaVersion: 1;
|
|
14
|
+
name: string;
|
|
15
|
+
description: string;
|
|
16
|
+
instructions: string;
|
|
17
|
+
toolScope: string[];
|
|
18
|
+
maxSteps: number;
|
|
19
|
+
maxTokens: number;
|
|
20
|
+
path: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type AgentProfileResult =
|
|
24
|
+
| { ok: true; profile: AgentProfile }
|
|
25
|
+
| { ok: false; error: string };
|
|
26
|
+
|
|
27
|
+
export function agentProfilesRoot(): string {
|
|
28
|
+
return path.join(causyneHome(), 'agents');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function cleanLine(value: unknown, maximum: number): string {
|
|
32
|
+
return String(value ?? '')
|
|
33
|
+
// eslint-disable-next-line no-control-regex
|
|
34
|
+
.replace(/[\x00-\x1f\x7f]/g, ' ')
|
|
35
|
+
.replace(/\s+/g, ' ')
|
|
36
|
+
.trim()
|
|
37
|
+
.slice(0, maximum);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function cleanInstructions(value: unknown): string {
|
|
41
|
+
return String(value ?? '')
|
|
42
|
+
// Preserve newlines and tabs while excluding prompt-control bytes.
|
|
43
|
+
// eslint-disable-next-line no-control-regex
|
|
44
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
|
45
|
+
.trim();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function boundedInteger(value: unknown, fallback: number, minimum: number, maximum: number): number | null {
|
|
49
|
+
if (value === undefined) return fallback;
|
|
50
|
+
if (!Number.isInteger(value)) return null;
|
|
51
|
+
return Math.max(minimum, Math.min(Number(value), maximum));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseProfile(file: string, expectedName: string): AgentProfileResult {
|
|
55
|
+
try {
|
|
56
|
+
const stat = fs.lstatSync(file);
|
|
57
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return { ok: false, error: 'profile must be a regular file' };
|
|
58
|
+
if (stat.size > MAX_PROFILE_BYTES) return { ok: false, error: `profile exceeds ${MAX_PROFILE_BYTES / 1024} KB` };
|
|
59
|
+
const raw: unknown = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
60
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
61
|
+
return { ok: false, error: 'profile must be a JSON object' };
|
|
62
|
+
}
|
|
63
|
+
const value = raw as Record<string, unknown>;
|
|
64
|
+
if (value.schemaVersion !== 1) return { ok: false, error: 'schemaVersion must be 1' };
|
|
65
|
+
if (value.name !== expectedName || !PROFILE_ID.test(expectedName)) {
|
|
66
|
+
return { ok: false, error: 'profile name must match its lowercase file name' };
|
|
67
|
+
}
|
|
68
|
+
const description = cleanLine(value.description, 400);
|
|
69
|
+
if (!description) return { ok: false, error: 'description is required' };
|
|
70
|
+
const instructions = cleanInstructions(value.instructions);
|
|
71
|
+
if (!instructions) return { ok: false, error: 'instructions are required' };
|
|
72
|
+
if (Buffer.byteLength(instructions, 'utf8') > MAX_INSTRUCTIONS) {
|
|
73
|
+
return { ok: false, error: `instructions exceed ${MAX_INSTRUCTIONS / 1024} KB` };
|
|
74
|
+
}
|
|
75
|
+
if (!Array.isArray(value.toolScope) || value.toolScope.length === 0 || value.toolScope.length > MAX_TOOLS) {
|
|
76
|
+
return { ok: false, error: `toolScope must contain 1-${MAX_TOOLS} tool names` };
|
|
77
|
+
}
|
|
78
|
+
const toolScope: string[] = [];
|
|
79
|
+
for (const rawTool of value.toolScope) {
|
|
80
|
+
if (typeof rawTool !== 'string' || !TOOL_NAME.test(rawTool)) {
|
|
81
|
+
return { ok: false, error: `invalid tool name ${JSON.stringify(rawTool)}` };
|
|
82
|
+
}
|
|
83
|
+
const normalized = rawTool.replace(/\./g, '_');
|
|
84
|
+
if (!toolScope.includes(normalized)) toolScope.push(normalized);
|
|
85
|
+
}
|
|
86
|
+
const maxSteps = boundedInteger(value.maxSteps, 25, 1, 100);
|
|
87
|
+
if (maxSteps === null) return { ok: false, error: 'maxSteps must be an integer' };
|
|
88
|
+
const maxTokens = boundedInteger(value.maxTokens, 50_000, 1_000, 200_000);
|
|
89
|
+
if (maxTokens === null) return { ok: false, error: 'maxTokens must be an integer' };
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
profile: {
|
|
94
|
+
schemaVersion: 1,
|
|
95
|
+
name: expectedName,
|
|
96
|
+
description,
|
|
97
|
+
instructions,
|
|
98
|
+
toolScope,
|
|
99
|
+
maxSteps,
|
|
100
|
+
maxTokens,
|
|
101
|
+
path: file
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
} catch (error) {
|
|
105
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
106
|
+
return { ok: false, error: message };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function loadAgentProfile(name: unknown): AgentProfileResult {
|
|
111
|
+
const id = String(name ?? '');
|
|
112
|
+
if (!PROFILE_ID.test(id)) return { ok: false, error: 'invalid profile name' };
|
|
113
|
+
return parseProfile(path.join(agentProfilesRoot(), `${id}.json`), id);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function listAgentProfiles(): { profiles: AgentProfile[]; problems: string[] } {
|
|
117
|
+
const profiles: AgentProfile[] = [];
|
|
118
|
+
const problems: string[] = [];
|
|
119
|
+
const root = agentProfilesRoot();
|
|
120
|
+
try {
|
|
121
|
+
if (!fs.existsSync(root)) return { profiles, problems };
|
|
122
|
+
const entries = fs.readdirSync(root, { withFileTypes: true })
|
|
123
|
+
.filter((entry) => entry.name.endsWith('.json') && !entry.name.startsWith('.'))
|
|
124
|
+
.slice(0, MAX_PROFILES + 1);
|
|
125
|
+
if (entries.length > MAX_PROFILES) problems.push(`only the first ${MAX_PROFILES} profiles were inspected`);
|
|
126
|
+
for (const entry of entries.slice(0, MAX_PROFILES)) {
|
|
127
|
+
const name = entry.name.slice(0, -5);
|
|
128
|
+
if (!entry.isFile() || !PROFILE_ID.test(name)) {
|
|
129
|
+
problems.push(`${entry.name}: invalid profile file`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const loaded = parseProfile(path.join(root, entry.name), name);
|
|
133
|
+
if (loaded.ok) profiles.push(loaded.profile);
|
|
134
|
+
else problems.push(`${entry.name}: ${loaded.error}`);
|
|
135
|
+
}
|
|
136
|
+
} catch (error) {
|
|
137
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
138
|
+
problems.push(`could not read profiles: ${message}`);
|
|
139
|
+
}
|
|
140
|
+
return { profiles, problems };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export { MAX_PROFILES, MAX_PROFILE_BYTES, MAX_INSTRUCTIONS, MAX_TOOLS };
|