jules-orchestrator-kit 0.75.0 → 0.75.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -1
- package/JULES_RULES_TEMPLATE.md +3 -0
- package/README.md +4 -2
- package/bin/agentctl.mjs +52 -24
- package/package.json +2 -2
- package/scripts/ci-scope-guard.mjs +197 -17
- package/scripts/run-tests.mjs +5 -1
- package/src/ci-templates.mjs +185 -21
- package/src/engine.mjs +167 -4
- package/src/envelope.mjs +17 -1
- package/src/evidence.mjs +22 -5
- package/src/ops/command-registry.mjs +4 -1
- package/src/ops/tui-menu.mjs +4 -2
- package/src/prompt-guard.mjs +9 -0
- package/src/provider.mjs +14 -0
- package/src/secret-scanner.mjs +13 -1
- package/src/security.mjs +4 -0
- package/src/state.mjs +23 -9
- package/src/wizard-task.mjs +9 -20
package/src/ci-templates.mjs
CHANGED
|
@@ -149,51 +149,215 @@ ${extraSteps ? extraSteps + "\n" : ""}
|
|
|
149
149
|
}
|
|
150
150
|
|
|
151
151
|
/**
|
|
152
|
-
*
|
|
152
|
+
* Generate a workflow_run auto-merge workflow for GitHub Actions that safely
|
|
153
|
+
* merges qualifying green PRs opened by authorized automation bots.
|
|
154
|
+
*
|
|
155
|
+
* Implements a strict trust boundary:
|
|
156
|
+
* - Executes exclusively on the base branch (default `main`)
|
|
157
|
+
* - Evaluates PR commits strictly as unexecuted data (fetch + git diff)
|
|
158
|
+
* - Verifies the bot actor against authorized identities (default `google-labs-jules[bot]`)
|
|
159
|
+
* - Merges cleanly via `gh pr merge --squash --delete-branch`
|
|
160
|
+
*
|
|
161
|
+
* @param {object} opts
|
|
162
|
+
* @param {object} [opts.config]
|
|
163
|
+
* @param {string} [opts.version]
|
|
164
|
+
* @param {string[]} [opts.allowedActors]
|
|
165
|
+
* @returns {{ file: string, content: string }}
|
|
166
|
+
*/
|
|
167
|
+
export function buildAutoMergeWorkflow(opts = {}) {
|
|
168
|
+
const base = opts.config?.baseBranch || opts.baseBranch || "main";
|
|
169
|
+
const version = opts.version || "0";
|
|
170
|
+
const defaultActors = ["google-labs-jules[bot]", "github-actions[bot]"];
|
|
171
|
+
const allowedActors = opts.allowedActors || defaultActors;
|
|
172
|
+
const actorCases = allowedActors.map((a) => `"${a}"`).join("|");
|
|
173
|
+
|
|
174
|
+
const content = `# Generated by jules-orchestrator-kit v${version}.
|
|
175
|
+
#
|
|
176
|
+
# Auto-merge verified pull requests opened by authorized automation bots.
|
|
177
|
+
#
|
|
178
|
+
# TRUST BOUNDARY ARCHITECTURE:
|
|
179
|
+
# For pull requests from repository branches, GitHub Actions runs
|
|
180
|
+
# 'on: pull_request' workflows from the PR branch head. A pull request can
|
|
181
|
+
# therefore supply its own modified workflow code.
|
|
182
|
+
#
|
|
183
|
+
# This workflow runs on 'workflow_run' after 'Agent Safety Gate' completes.
|
|
184
|
+
# It executes STRICTLY from the default branch (${base}), ensuring that:
|
|
185
|
+
# 1. It only runs code, scripts, and policies committed to ${base}.
|
|
186
|
+
# 2. The pull request commits are fetched strictly as unexecuted data.
|
|
187
|
+
# 3. Privileged write tokens are never exposed to the untrusted PR branch.
|
|
188
|
+
#
|
|
189
|
+
# Regenerate with: agentctl ci init --with-automerge --force
|
|
190
|
+
|
|
191
|
+
name: Agent Auto-Merge
|
|
192
|
+
|
|
193
|
+
on:
|
|
194
|
+
workflow_run:
|
|
195
|
+
workflows: ["Agent Safety Gate"]
|
|
196
|
+
types: [completed]
|
|
197
|
+
|
|
198
|
+
permissions:
|
|
199
|
+
contents: read
|
|
200
|
+
|
|
201
|
+
concurrency:
|
|
202
|
+
group: agent-automerge-\${{ github.event.workflow_run.head_sha }}
|
|
203
|
+
cancel-in-progress: false
|
|
204
|
+
|
|
205
|
+
jobs:
|
|
206
|
+
automerge:
|
|
207
|
+
if: >-
|
|
208
|
+
github.event.workflow_run.conclusion == 'success' &&
|
|
209
|
+
github.event.workflow_run.event == 'pull_request' &&
|
|
210
|
+
github.event.workflow_run.head_repository.full_name == github.repository
|
|
211
|
+
runs-on: ubuntu-latest
|
|
212
|
+
permissions:
|
|
213
|
+
contents: write
|
|
214
|
+
pull-requests: write
|
|
215
|
+
env:
|
|
216
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
217
|
+
REPO: \${{ github.repository }}
|
|
218
|
+
HEAD_SHA: \${{ github.event.workflow_run.head_sha }}
|
|
219
|
+
ACTOR: \${{ github.event.workflow_run.actor.login }}
|
|
220
|
+
steps:
|
|
221
|
+
- name: Checkout base revision (${base})
|
|
222
|
+
uses: actions/checkout@v4
|
|
223
|
+
with:
|
|
224
|
+
ref: ${base}
|
|
225
|
+
fetch-depth: 0
|
|
226
|
+
persist-credentials: false
|
|
227
|
+
|
|
228
|
+
- name: Verify authorized automation actor
|
|
229
|
+
run: |
|
|
230
|
+
set -euo pipefail
|
|
231
|
+
case "$ACTOR" in
|
|
232
|
+
${actorCases})
|
|
233
|
+
echo "Authorized automation actor: $ACTOR"
|
|
234
|
+
;;
|
|
235
|
+
*)
|
|
236
|
+
echo "Actor '$ACTOR' is not an authorized automation bot; leaving PR for human review."
|
|
237
|
+
exit 0
|
|
238
|
+
;;
|
|
239
|
+
esac
|
|
240
|
+
|
|
241
|
+
- id: pr
|
|
242
|
+
name: Resolve Pull Request
|
|
243
|
+
run: |
|
|
244
|
+
set -euo pipefail
|
|
245
|
+
num=$(gh pr list --repo "$REPO" --state open --base "${base}" \\
|
|
246
|
+
--json number,headRefOid \\
|
|
247
|
+
--jq "[.[] | select(.headRefOid == \\"$HEAD_SHA\\")] | .[0].number // empty")
|
|
248
|
+
if [ -z "$num" ]; then
|
|
249
|
+
echo "No open PR found for head SHA $HEAD_SHA; nothing to merge."
|
|
250
|
+
echo "found=false" >> "$GITHUB_OUTPUT"
|
|
251
|
+
exit 0
|
|
252
|
+
fi
|
|
253
|
+
echo "Resolved PR #$num for $HEAD_SHA"
|
|
254
|
+
echo "number=$num" >> "$GITHUB_OUTPUT"
|
|
255
|
+
echo "found=true" >> "$GITHUB_OUTPUT"
|
|
256
|
+
|
|
257
|
+
- name: Fetch PR commits as untrusted data
|
|
258
|
+
if: steps.pr.outputs.found == 'true'
|
|
259
|
+
run: |
|
|
260
|
+
set -euo pipefail
|
|
261
|
+
git fetch --no-tags origin "+$HEAD_SHA:refs/pr-head"
|
|
262
|
+
git checkout --detach refs/pr-head
|
|
263
|
+
|
|
264
|
+
- name: Set up Node.js
|
|
265
|
+
if: steps.pr.outputs.found == 'true'
|
|
266
|
+
uses: actions/setup-node@v4
|
|
267
|
+
with:
|
|
268
|
+
node-version: "20"
|
|
269
|
+
|
|
270
|
+
- name: Verify safety gate from base
|
|
271
|
+
if: steps.pr.outputs.found == 'true'
|
|
272
|
+
run: |
|
|
273
|
+
npx --yes jules-orchestrator-kit check --mode committed --base "${base}"
|
|
274
|
+
|
|
275
|
+
- name: Squash and merge pull request
|
|
276
|
+
if: steps.pr.outputs.found == 'true'
|
|
277
|
+
run: |
|
|
278
|
+
gh pr merge --squash --delete-branch "\${{ steps.pr.outputs.number }}" --repo "$REPO"
|
|
279
|
+
`;
|
|
280
|
+
|
|
281
|
+
return { file: ".github/workflows/agent-automerge.yml", content };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Write the generated workflow(s), refusing to clobber existing files unless
|
|
153
286
|
* asked.
|
|
154
287
|
*
|
|
155
288
|
* @param {string} root
|
|
156
|
-
* @param {object} [opts] - see {@link buildCiWorkflow}, plus `force` and `
|
|
157
|
-
* @returns {{ ok: boolean, file?: string, written?: boolean, stack?: string, setupSummary?: string, content?: string, error?: string }}
|
|
289
|
+
* @param {object} [opts] - see {@link buildCiWorkflow}, plus `force`, `dryRun`, and `withAutomerge`
|
|
290
|
+
* @returns {{ ok: boolean, file?: string, files?: string[], written?: boolean, stack?: string, setupSummary?: string, content?: string, error?: string }}
|
|
158
291
|
*/
|
|
159
292
|
export function writeCiWorkflow(root, opts = {}) {
|
|
293
|
+
const target = CI_TARGETS.includes(opts.target) ? opts.target : (opts.target ? opts.target : "github");
|
|
160
294
|
if (opts.target && !CI_TARGETS.includes(opts.target)) {
|
|
161
295
|
return { ok: false, error: `Unknown CI target '${opts.target}'. Choose one of: ${CI_TARGETS.join(", ")}` };
|
|
162
296
|
}
|
|
163
297
|
|
|
298
|
+
const withAutomerge = Boolean(opts.withAutomerge || opts["with-automerge"]);
|
|
299
|
+
if (withAutomerge && target !== "github") {
|
|
300
|
+
return { ok: false, error: "Auto-merge workflow is currently only supported for GitHub Actions." };
|
|
301
|
+
}
|
|
302
|
+
|
|
164
303
|
const built = buildCiWorkflow(opts);
|
|
165
304
|
const stackId = opts.stack?.stack || "unknown";
|
|
166
|
-
const
|
|
305
|
+
const itemsToWrite = [built];
|
|
167
306
|
|
|
168
|
-
if (
|
|
169
|
-
|
|
307
|
+
if (withAutomerge) {
|
|
308
|
+
itemsToWrite.push(buildAutoMergeWorkflow(opts));
|
|
170
309
|
}
|
|
171
310
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
identical = readFileSync(abs, "utf-8") === built.content;
|
|
176
|
-
} catch (_) {
|
|
177
|
-
// Unreadable target: treat as different so the caller is told to --force
|
|
178
|
-
// rather than being told nothing changed.
|
|
179
|
-
}
|
|
311
|
+
const files = itemsToWrite.map((item) => item.file);
|
|
312
|
+
|
|
313
|
+
if (opts.dryRun) {
|
|
180
314
|
return {
|
|
181
315
|
ok: true,
|
|
182
316
|
file: built.file,
|
|
317
|
+
files,
|
|
183
318
|
written: false,
|
|
184
|
-
identical,
|
|
185
319
|
stack: stackId,
|
|
186
320
|
setupSummary: built.setupSummary,
|
|
187
321
|
content: built.content,
|
|
322
|
+
workflows: itemsToWrite,
|
|
188
323
|
};
|
|
189
324
|
}
|
|
190
325
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
326
|
+
let allWritten = true;
|
|
327
|
+
let allIdentical = true;
|
|
328
|
+
for (const item of itemsToWrite) {
|
|
329
|
+
const abs = join(root, item.file);
|
|
330
|
+
if (existsSync(abs) && !opts.force) {
|
|
331
|
+
let identical = false;
|
|
332
|
+
try {
|
|
333
|
+
identical = readFileSync(abs, "utf-8") === item.content;
|
|
334
|
+
} catch (_) {
|
|
335
|
+
// Unreadable target: treat as different so the caller is told to --force
|
|
336
|
+
// rather than being told nothing changed.
|
|
337
|
+
}
|
|
338
|
+
if (!identical) {
|
|
339
|
+
allIdentical = false;
|
|
340
|
+
}
|
|
341
|
+
allWritten = false;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
try {
|
|
346
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
347
|
+
writeFileSync(abs, item.content, "utf-8");
|
|
348
|
+
} catch (err) {
|
|
349
|
+
return { ok: false, error: `Could not write ${item.file}: ${err.message}` };
|
|
350
|
+
}
|
|
196
351
|
}
|
|
197
352
|
|
|
198
|
-
return {
|
|
353
|
+
return {
|
|
354
|
+
ok: true,
|
|
355
|
+
file: built.file,
|
|
356
|
+
files,
|
|
357
|
+
written: allWritten,
|
|
358
|
+
identical: allWritten ? false : allIdentical,
|
|
359
|
+
stack: stackId,
|
|
360
|
+
setupSummary: built.setupSummary,
|
|
361
|
+
content: built.content,
|
|
362
|
+
};
|
|
199
363
|
}
|
package/src/engine.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { loadConfig, resolveTrustedPolicy, normalizePath } from "./config.mjs";
|
|
|
2
2
|
import { isTestPath } from "./test-paths.mjs";
|
|
3
3
|
import { isPlaceholderTestScript, isSrcLayout } from "./stack-detector.mjs";
|
|
4
4
|
import { checkCollectionFloor } from "./ops/test-collection.mjs";
|
|
5
|
-
import { checkScope, scanDiff, scanBinaryPayloads, redactSecrets } from "./security.mjs";
|
|
5
|
+
import { checkScope, scanDiff, scanBinaryPayloads, redactSecrets, hasHighConfidenceSecret } from "./security.mjs";
|
|
6
6
|
import { changedFiles, diffBytes, diffText, binaryDiffEntries, symlinkChanges, showFromOrigin, runCmd, materializeSnapshot } from "./git.mjs";
|
|
7
7
|
import { createProvider, ProviderRateLimitError, ProviderUnavailableError } from "./provider.mjs";
|
|
8
8
|
import { resolveRoutedProvider } from "./router.mjs";
|
|
@@ -306,7 +306,7 @@ export async function gate(opts = {}) {
|
|
|
306
306
|
let snapshot = { cwd: root, cleanup: () => {}, mode };
|
|
307
307
|
try {
|
|
308
308
|
if (mode === "staged" || mode === "committed") {
|
|
309
|
-
snapshot = materializeSnapshot(root, mode,
|
|
309
|
+
snapshot = materializeSnapshot(root, mode, opts.head || "HEAD");
|
|
310
310
|
}
|
|
311
311
|
} catch (snapErr) {
|
|
312
312
|
phases.push({ phase: "verify", ok: false, error: snapErr.message });
|
|
@@ -947,6 +947,11 @@ export async function repair(failure, opts = {}) {
|
|
|
947
947
|
dryRun: opts.dryRun,
|
|
948
948
|
pollIntervalMs: opts.pollIntervalMs,
|
|
949
949
|
maxPollAttempts: opts.maxPollAttempts,
|
|
950
|
+
autoApprovePlan: opts.autoApprovePlan || opts.autoApprove,
|
|
951
|
+
autoReply: opts.autoReply,
|
|
952
|
+
maxAutoReplies: opts.maxAutoReplies,
|
|
953
|
+
autoReplyFn: opts.autoReplyFn,
|
|
954
|
+
task: opts.task || { id: `repair-${n}`, title: `OODA Auto-Repair Attempt ${n}`, prompt: repairPrompt },
|
|
950
955
|
});
|
|
951
956
|
|
|
952
957
|
// The gate below is the authority on whether the change works, so it runs
|
|
@@ -1139,6 +1144,54 @@ export const TERMINAL_SESSION_STATES = new Set(["COMPLETED", "FAILED"]);
|
|
|
1139
1144
|
*/
|
|
1140
1145
|
export const BLOCKING_SESSION_STATES = new Set(["AWAITING_PLAN_APPROVAL", "AWAITING_USER_FEEDBACK", "PAUSED"]);
|
|
1141
1146
|
|
|
1147
|
+
/**
|
|
1148
|
+
* Synthesizes an autonomous, envelope-grounded reply directive when Jules halts
|
|
1149
|
+
* in AWAITING_USER_FEEDBACK during unattended execution.
|
|
1150
|
+
*
|
|
1151
|
+
* @param {object|null} task - Task envelope or task definition object.
|
|
1152
|
+
* @param {string} [question] - Extracted question text from the agent.
|
|
1153
|
+
* @returns {string} Deterministic directive for the agent to continue.
|
|
1154
|
+
*/
|
|
1155
|
+
export function buildAutoReplyPrompt(task = null, question = "") {
|
|
1156
|
+
const parts = [];
|
|
1157
|
+
parts.push("Autonomous execution directive:");
|
|
1158
|
+
|
|
1159
|
+
if (task && typeof task === "object") {
|
|
1160
|
+
const scope = task.scope?.allow || task.allowedFiles || task.targetFiles;
|
|
1161
|
+
if (Array.isArray(scope) && scope.length > 0) {
|
|
1162
|
+
parts.push(`- Scope constraint: modify only files matching ${scope.join(", ")}.`);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const invariants = Array.isArray(task.invariants) ? task.invariants : [];
|
|
1166
|
+
if (invariants.length > 0) {
|
|
1167
|
+
parts.push(`- Invariants: ${invariants.slice(0, 5).join("; ")}.`);
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
if (task.title || task.objective) {
|
|
1171
|
+
parts.push(`- Objective: ${task.title || task.objective}.`);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
if (task.clarifications && typeof task.clarifications === "object") {
|
|
1175
|
+
const entries = Object.entries(task.clarifications);
|
|
1176
|
+
if (entries.length > 0) {
|
|
1177
|
+
const qLower = String(question || "").toLowerCase();
|
|
1178
|
+
for (const [k, v] of entries) {
|
|
1179
|
+
if (qLower.includes(k.toLowerCase())) {
|
|
1180
|
+
parts.push(`- Pre-approved clarification (${k}): ${v}`);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
parts.push(
|
|
1188
|
+
"- Decision rule: Make reasonable engineering assumptions that satisfy tests and maintain existing API contracts.",
|
|
1189
|
+
"- Do not pause for human confirmation or additional feedback. Proceed with implementation, verification, and completion."
|
|
1190
|
+
);
|
|
1191
|
+
|
|
1192
|
+
return parts.join("\n");
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1142
1195
|
/**
|
|
1143
1196
|
* Polls the provider until the session reaches a terminal state, blocks on an
|
|
1144
1197
|
* actor, or the poll budget runs out.
|
|
@@ -1180,6 +1233,8 @@ export async function pollSessionState(provider, session, opts = {}) {
|
|
|
1180
1233
|
const startTime = Date.now();
|
|
1181
1234
|
let lastStatus = "";
|
|
1182
1235
|
let polls = 0;
|
|
1236
|
+
const repliedActivityIds = new Set();
|
|
1237
|
+
let autoRepliesCount = 0;
|
|
1183
1238
|
|
|
1184
1239
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
1185
1240
|
if (Date.now() - startTime > timeoutMs) break;
|
|
@@ -1235,6 +1290,102 @@ export async function pollSessionState(provider, session, opts = {}) {
|
|
|
1235
1290
|
}
|
|
1236
1291
|
}
|
|
1237
1292
|
|
|
1293
|
+
// Autonomously answer clarifying questions if requested
|
|
1294
|
+
const isUserFeedback = status === "AWAITING_USER_FEEDBACK";
|
|
1295
|
+
const wantsAutoReply = Boolean(opts.autoReply || session.autoReply);
|
|
1296
|
+
const canSend = provider && (typeof provider.sendMessage === "function" || typeof provider.resume === "function");
|
|
1297
|
+
if (isUserFeedback && wantsAutoReply && canSend) {
|
|
1298
|
+
const maxAutoReplies = Number(opts.maxAutoReplies ?? session.maxAutoReplies ?? 3);
|
|
1299
|
+
if (autoRepliesCount >= maxAutoReplies) {
|
|
1300
|
+
return {
|
|
1301
|
+
...currentSession,
|
|
1302
|
+
status,
|
|
1303
|
+
terminal: false,
|
|
1304
|
+
blockedOn: status,
|
|
1305
|
+
autoReplyLimitExceeded: true,
|
|
1306
|
+
autoReplies: autoRepliesCount,
|
|
1307
|
+
polls,
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
let question = "";
|
|
1312
|
+
let activityId = null;
|
|
1313
|
+
if (typeof provider.listActivities === "function") {
|
|
1314
|
+
try {
|
|
1315
|
+
const actRes = await provider.listActivities(session.id, opts);
|
|
1316
|
+
const activities = Array.isArray(actRes?.activities) ? actRes.activities : [];
|
|
1317
|
+
for (let i = activities.length - 1; i >= 0; i--) {
|
|
1318
|
+
const act = activities[i];
|
|
1319
|
+
const msg = act.agentMessaged?.agentMessage || act.message;
|
|
1320
|
+
if (msg) {
|
|
1321
|
+
question = String(msg).trim();
|
|
1322
|
+
activityId = act.name || act.id || null;
|
|
1323
|
+
break;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
} catch (_) {}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// Idempotency: If this activity was already answered, wait for the agent to advance rather than duplicate
|
|
1330
|
+
if (activityId && repliedActivityIds.has(activityId)) {
|
|
1331
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
1332
|
+
continue;
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
let reply = "";
|
|
1336
|
+
const taskContext = opts.task || session.task || null;
|
|
1337
|
+
if (typeof opts.autoReplyFn === "function") {
|
|
1338
|
+
try {
|
|
1339
|
+
reply = await opts.autoReplyFn({ session: currentSession, question, activityId, task: taskContext });
|
|
1340
|
+
} catch (err) {
|
|
1341
|
+
return {
|
|
1342
|
+
...currentSession,
|
|
1343
|
+
status,
|
|
1344
|
+
terminal: false,
|
|
1345
|
+
blockedOn: status,
|
|
1346
|
+
autoReplyError: `autoReplyFn failed: ${err.message}`,
|
|
1347
|
+
autoReplies: autoRepliesCount,
|
|
1348
|
+
polls,
|
|
1349
|
+
};
|
|
1350
|
+
}
|
|
1351
|
+
} else {
|
|
1352
|
+
reply = buildAutoReplyPrompt(taskContext, question);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
if (activityId) {
|
|
1356
|
+
repliedActivityIds.add(activityId);
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
try {
|
|
1360
|
+
const sendFn = typeof provider.sendMessage === "function"
|
|
1361
|
+
? provider.sendMessage.bind(provider)
|
|
1362
|
+
: provider.resume.bind(provider);
|
|
1363
|
+
await sendFn(session.id, reply, opts);
|
|
1364
|
+
autoRepliesCount += 1;
|
|
1365
|
+
if (opts.root) {
|
|
1366
|
+
appendTelemetry(opts.root, "session_auto_reply", {
|
|
1367
|
+
sessionId: session.id,
|
|
1368
|
+
question: question ? question.slice(0, 300) : null,
|
|
1369
|
+
reply: reply ? reply.slice(0, 300) : null,
|
|
1370
|
+
replyCount: autoRepliesCount,
|
|
1371
|
+
activityId,
|
|
1372
|
+
});
|
|
1373
|
+
}
|
|
1374
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
1375
|
+
continue;
|
|
1376
|
+
} catch (err) {
|
|
1377
|
+
return {
|
|
1378
|
+
...currentSession,
|
|
1379
|
+
status,
|
|
1380
|
+
terminal: false,
|
|
1381
|
+
blockedOn: status,
|
|
1382
|
+
autoReplyError: err && err.message ? err.message : String(err),
|
|
1383
|
+
autoReplies: autoRepliesCount,
|
|
1384
|
+
polls,
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1238
1389
|
return { ...currentSession, status, terminal: false, blockedOn: status, polls };
|
|
1239
1390
|
}
|
|
1240
1391
|
|
|
@@ -1331,8 +1482,14 @@ export async function dispatch(task = {}, opts = {}) {
|
|
|
1331
1482
|
};
|
|
1332
1483
|
}
|
|
1333
1484
|
|
|
1485
|
+
// Pre-dispatch secret leak prevention (unless explicitly allowed)
|
|
1486
|
+
const rawPrompt = task.prompt || "";
|
|
1487
|
+
if (!opts.allowSecrets && hasHighConfidenceSecret(rawPrompt)) {
|
|
1488
|
+
throw new Error("Pre-Dispatch Secret Leak Blocked: Task prompt contains unredacted high-confidence credentials.");
|
|
1489
|
+
}
|
|
1490
|
+
|
|
1334
1491
|
// Redact secrets in prompt before dispatching
|
|
1335
|
-
let cleanPrompt = redactSecrets(
|
|
1492
|
+
let cleanPrompt = redactSecrets(rawPrompt);
|
|
1336
1493
|
|
|
1337
1494
|
// Specialist Role resolution (if role is set and not already present in prompt)
|
|
1338
1495
|
if (task.role) {
|
|
@@ -1745,7 +1902,13 @@ export async function probeDevServer(serverConfig = {}, root = process.cwd()) {
|
|
|
1745
1902
|
if (isWin) {
|
|
1746
1903
|
spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
|
|
1747
1904
|
} else {
|
|
1748
|
-
|
|
1905
|
+
const pgid = -child.pid;
|
|
1906
|
+
process.kill(pgid, "SIGTERM");
|
|
1907
|
+
setTimeout(() => {
|
|
1908
|
+
try {
|
|
1909
|
+
process.kill(pgid, "SIGKILL");
|
|
1910
|
+
} catch (_) {}
|
|
1911
|
+
}, 2000).unref();
|
|
1749
1912
|
}
|
|
1750
1913
|
} catch (_) {}
|
|
1751
1914
|
}
|
package/src/envelope.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { git, runCmd, resolveBase } from "./git.mjs";
|
|
2
|
-
import { checkScope } from "./security.mjs";
|
|
2
|
+
import { checkScope, scanDiff } from "./security.mjs";
|
|
3
3
|
import { normalizeScope } from "./config.mjs";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { resolve, relative, isAbsolute } from "node:path";
|
|
@@ -185,6 +185,22 @@ export function validateEnvelope(envelope = {}, opts = {}) {
|
|
|
185
185
|
}
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
// 11. Secret Leak Scrubbing Preflight
|
|
189
|
+
const textToScan = [
|
|
190
|
+
typeof envelope.prompt === "string" ? envelope.prompt : "",
|
|
191
|
+
typeof envelope.intent === "string" ? envelope.intent : (envelope.intent?.outcome || ""),
|
|
192
|
+
typeof envelope.instructions === "string" ? envelope.instructions : "",
|
|
193
|
+
].filter(Boolean).join("\n");
|
|
194
|
+
|
|
195
|
+
if (textToScan && !opts.allowSecrets) {
|
|
196
|
+
const multilineDiff = textToScan.split("\n").map((line) => `+${line}`).join("\n");
|
|
197
|
+
const secretScan = scanDiff(multilineDiff);
|
|
198
|
+
if (!secretScan.ok && secretScan.findings.length > 0) {
|
|
199
|
+
const types = secretScan.findings.map((f) => f.type || "SECRET_DETECTED").join(", ");
|
|
200
|
+
errors.push(`Secret leak detected in envelope prompt/intent (${types}). Scrub credentials before validation.`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
188
204
|
return {
|
|
189
205
|
ok: errors.length === 0,
|
|
190
206
|
code: errors.length === 0 ? 0 : 1,
|
package/src/evidence.mjs
CHANGED
|
@@ -135,14 +135,21 @@ export function computeDirectoryHash(root, options = {}) {
|
|
|
135
135
|
// The named directories stay as a fast path; when they yield nothing, walk
|
|
136
136
|
// the repository and let the shared predicate decide. The walk already
|
|
137
137
|
// skips node_modules, vendor, target and the build caches.
|
|
138
|
-
const targetDirs = options.directories || ["test", "tests", "__tests__", "spec", "specs", "src"];
|
|
138
|
+
const targetDirs = options.directories || ["test", "tests", "__tests__", "spec", "specs", "src", "scripts", "bin"];
|
|
139
139
|
for (const dirName of targetDirs) {
|
|
140
140
|
const dirPath = join(root, dirName);
|
|
141
141
|
if (existsSync(dirPath)) {
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
if (statSync(dirPath).isFile()) {
|
|
143
|
+
fileList.push(normalizePath(dirName));
|
|
144
|
+
} else {
|
|
145
|
+
const found = findFilesRecursively(dirPath, root);
|
|
146
|
+
fileList.push(...found);
|
|
147
|
+
}
|
|
144
148
|
}
|
|
145
149
|
}
|
|
150
|
+
if (existsSync(join(root, "package.json"))) {
|
|
151
|
+
fileList.push("package.json");
|
|
152
|
+
}
|
|
146
153
|
if (options.testOnly && !fileList.some((f) => isTestPath(f))) {
|
|
147
154
|
for (const f of findFilesRecursively(root, root)) {
|
|
148
155
|
if (isTestPath(f)) fileList.push(f);
|
|
@@ -191,7 +198,8 @@ export function computeDirectoryHash(root, options = {}) {
|
|
|
191
198
|
}
|
|
192
199
|
}
|
|
193
200
|
|
|
194
|
-
|
|
201
|
+
// Join with null byte to prevent path delimiter injection (F-03)
|
|
202
|
+
const treeHash = "sha256:" + sha256(hashLines.join("\0"));
|
|
195
203
|
return {
|
|
196
204
|
treeHash,
|
|
197
205
|
fileCount: hashLines.length,
|
|
@@ -511,9 +519,18 @@ export function verifyEvidenceManifest(root = process.cwd(), manifestOrPath = "m
|
|
|
511
519
|
return { ok: false, reason: "Evidence manifest hash signature mismatch (tampered file)" };
|
|
512
520
|
}
|
|
513
521
|
|
|
522
|
+
// 1.5 Verify recorded execution outcome (F-08)
|
|
523
|
+
if (manifest.status && !["passed", "success", "ok"].includes(manifest.status)) {
|
|
524
|
+
return { ok: false, reason: `Evidence manifest records failed execution outcome: ${manifest.status}` };
|
|
525
|
+
}
|
|
526
|
+
|
|
514
527
|
// 2. Verify test integrity / No test weakening
|
|
515
528
|
const currentTestState = computeDirectoryHash(root, { testOnly: true });
|
|
516
|
-
if (manifest.testIntegrity?.
|
|
529
|
+
if (!manifest.testIntegrity?.postTestHash) {
|
|
530
|
+
return { ok: false, reason: "Evidence manifest is missing test integrity baseline (empty shell)" };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (manifest.testIntegrity.tamperDetected) {
|
|
517
534
|
return {
|
|
518
535
|
ok: false,
|
|
519
536
|
reason: "Test suite tampering detected in evidence manifest",
|
|
@@ -373,9 +373,10 @@ export const COMMAND_REGISTRY = [
|
|
|
373
373
|
risk: "low",
|
|
374
374
|
interactive: "always",
|
|
375
375
|
requiresRepository: true,
|
|
376
|
-
shortcuts: ["tui", "ui"],
|
|
376
|
+
shortcuts: ["hub", "tui", "ui"],
|
|
377
377
|
examples: [
|
|
378
378
|
"agentctl menu",
|
|
379
|
+
"agentctl hub",
|
|
379
380
|
"agentctl tui",
|
|
380
381
|
],
|
|
381
382
|
flags: [],
|
|
@@ -1210,6 +1211,7 @@ export const COMMAND_REGISTRY = [
|
|
|
1210
1211
|
shortcuts: ["ci-init"],
|
|
1211
1212
|
examples: [
|
|
1212
1213
|
"agentctl ci init",
|
|
1214
|
+
"agentctl ci init --with-automerge",
|
|
1213
1215
|
"agentctl ci init --target gitlab --force",
|
|
1214
1216
|
"agentctl ci init --dry-run --json",
|
|
1215
1217
|
],
|
|
@@ -1218,6 +1220,7 @@ export const COMMAND_REGISTRY = [
|
|
|
1218
1220
|
{ name: "force", type: "boolean", description: "Overwrite existing workflow file (-f)" },
|
|
1219
1221
|
{ name: "dry-run", type: "boolean", description: "Report what would be written (-d)" },
|
|
1220
1222
|
{ name: "json", type: "boolean", description: "Output structured JSON result (-j)" },
|
|
1223
|
+
{ name: "with-automerge", type: "boolean", description: "Also generate a workflow_run auto-merge workflow" },
|
|
1221
1224
|
],
|
|
1222
1225
|
},
|
|
1223
1226
|
{
|
package/src/ops/tui-menu.mjs
CHANGED
|
@@ -27,9 +27,11 @@ export async function runTuiMenu(root = process.cwd(), options = {}) {
|
|
|
27
27
|
const stdin = options.stdin || process.stdin;
|
|
28
28
|
const stdout = options.stdout || process.stdout;
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
const hasCustomStreams = Boolean(options.stdin || options.stdout);
|
|
31
|
+
const isInteractive = isTTY(stdin) && isTTY(stdout) && (!process.env.CI || hasCustomStreams || options.allowInteractive);
|
|
32
|
+
if (!isInteractive && !options.allowHeadless) {
|
|
31
33
|
stdout.write("Interactive terminal menu requires a TTY terminal. Use 'agentctl --help' for CLI commands.\n");
|
|
32
|
-
return { ok: true, headless: true };
|
|
34
|
+
return { ok: true, headless: true, code: 1 };
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
while (true) {
|
package/src/prompt-guard.mjs
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* Strips zero-width unicode, bidi control characters, and ANSI escape sequences, normalizes UTF-8,
|
|
6
6
|
* neutralizes LLM control role markers and prompt injection patterns, and wraps inputs in strict tags.
|
|
7
7
|
*/
|
|
8
|
+
import { BIDI_CONTROL_REGEX, INVISIBLE_OBFUSCATION_REGEX } from "./bidi-guard.mjs";
|
|
9
|
+
import { CONFUSABLE_TO_ASCII } from "./secret-scanner.mjs";
|
|
10
|
+
|
|
11
|
+
const CONFUSABLE_REGEX = new RegExp([...CONFUSABLE_TO_ASCII.keys()].join("|"), "g");
|
|
8
12
|
|
|
9
13
|
const ZERO_WIDTH_AND_BIDI_REGEX = /[\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF]|[\u{E0000}-\u{E007F}]/gu;
|
|
10
14
|
const ANSI_ESCAPE_REGEX = /\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
|
|
@@ -100,8 +104,13 @@ export function sanitizeUntrustedData(input, sourceName = "untrusted") {
|
|
|
100
104
|
// 1. Normalize UTF-8 string (NFKC)
|
|
101
105
|
let text = input.normalize("NFKC");
|
|
102
106
|
|
|
107
|
+
// 1.5 Convert confusables to ASCII (transliterate Cyrillic/Greek lookalikes)
|
|
108
|
+
text = text.replace(CONFUSABLE_REGEX, (m) => CONFUSABLE_TO_ASCII.get(m));
|
|
109
|
+
|
|
103
110
|
// 2. Strip zero-width Unicode and bidi control characters
|
|
104
111
|
text = text.replace(ZERO_WIDTH_AND_BIDI_REGEX, "");
|
|
112
|
+
text = text.replace(BIDI_CONTROL_REGEX, "");
|
|
113
|
+
text = text.replace(INVISIBLE_OBFUSCATION_REGEX, "");
|
|
105
114
|
|
|
106
115
|
// 3. Strip ANSI terminal control sequences
|
|
107
116
|
text = text.replace(ANSI_ESCAPE_REGEX, "");
|
package/src/provider.mjs
CHANGED
|
@@ -716,6 +716,10 @@ export function createProvider(spec = "jules", config = {}) {
|
|
|
716
716
|
throw new Error(`Unsupported provider type: ${providerSpec.type}`);
|
|
717
717
|
},
|
|
718
718
|
|
|
719
|
+
async sendMessage(sessionId, prompt = "", ctx = {}, task = null) {
|
|
720
|
+
return this.resume(sessionId, prompt, ctx, task);
|
|
721
|
+
},
|
|
722
|
+
|
|
719
723
|
async getSession(sessionId, ctx = {}) {
|
|
720
724
|
if (!ctx || typeof ctx !== "object") ctx = {};
|
|
721
725
|
// `listSources()` reuses this method purely as an authenticated GET, and
|
|
@@ -1126,6 +1130,10 @@ export function createFailoverProvider(providers = ["jules"], config = {}) {
|
|
|
1126
1130
|
}
|
|
1127
1131
|
},
|
|
1128
1132
|
|
|
1133
|
+
async sendMessage(sessionId, prompt, ctx = {}, task = null) {
|
|
1134
|
+
return this.resume(sessionId, prompt, ctx, task);
|
|
1135
|
+
},
|
|
1136
|
+
|
|
1129
1137
|
async getSession(sessionId, ctx = {}) {
|
|
1130
1138
|
const errors = [];
|
|
1131
1139
|
for (let i = 0; i < providerList.length; i++) {
|
|
@@ -1263,6 +1271,12 @@ export function createSyntaxVerifiedProvider(fastProvider, complexProvider, conf
|
|
|
1263
1271
|
return fastProvider.resume(...args);
|
|
1264
1272
|
},
|
|
1265
1273
|
|
|
1274
|
+
sendMessage(...args) {
|
|
1275
|
+
return typeof fastProvider.sendMessage === "function"
|
|
1276
|
+
? fastProvider.sendMessage(...args)
|
|
1277
|
+
: fastProvider.resume(...args);
|
|
1278
|
+
},
|
|
1279
|
+
|
|
1266
1280
|
getSession(...args) {
|
|
1267
1281
|
return fastProvider.getSession(...args);
|
|
1268
1282
|
},
|