@tea-agent/loop-agent 0.22.0 → 0.24.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/AGENTS.md +42 -108
- package/CHANGELOG.md +85 -0
- package/README.md +8 -5
- package/bin/agent-worker.js +0 -0
- package/dist/application/context-usage/skill-resolution-stats.js +263 -0
- package/dist/application/dag/generate-task-dag.js +17 -3
- package/dist/cli/command-definitions.js +8 -7
- package/dist/cli/program.js +17 -15
- package/dist/commands/doctor.js +269 -18
- package/dist/commands/init.js +101 -86
- package/dist/commands/stats.js +40 -11
- package/dist/executors/shell-executor.js +20 -7
- package/dist/shared/operator/capabilities.js +486 -3
- package/dist/worker/console/app-data.js +6 -0
- package/dist/worker/console/chat/artifact-card.js +23 -0
- package/dist/worker/console/chat/chat-event-store.js +495 -0
- package/dist/worker/console/chat/chat-ui-policy.js +25 -0
- package/dist/worker/console/chat/composer-draft-store.js +45 -0
- package/dist/worker/console/chat/context-panel.js +54 -0
- package/dist/worker/console/chat/contract-apply-receipt-store.js +174 -0
- package/dist/worker/console/chat/explore-tools.js +299 -0
- package/dist/worker/console/chat/human-gate-card.js +37 -0
- package/dist/worker/console/chat/instruction-skills.js +217 -0
- package/dist/worker/console/chat/interview-adapter.js +136 -0
- package/dist/worker/console/chat/model-resolver.js +106 -0
- package/dist/worker/console/chat/operation-card.js +23 -0
- package/dist/worker/console/chat/pi-console-config.js +158 -0
- package/dist/worker/console/chat/pi-runtime.js +1143 -0
- package/dist/worker/console/chat/repo-browser.js +140 -0
- package/dist/worker/console/chat/repo-walk.js +116 -0
- package/dist/worker/console/chat/resource-loader.js +67 -0
- package/dist/worker/console/chat/routes.js +1646 -0
- package/dist/worker/console/chat/runtime-context.js +24 -0
- package/dist/worker/console/chat/runtime-selection.js +37 -0
- package/dist/worker/console/chat/session-store.js +437 -0
- package/dist/worker/console/chat/shortcuts.js +15 -0
- package/dist/worker/console/chat/tool-adapter.js +125 -0
- package/dist/worker/console/chat/tools.js +195 -0
- package/dist/worker/console/chat/usage.js +37 -0
- package/dist/worker/console/chat/workspace-landing.js +56 -0
- package/dist/worker/console/dag-confirmation.js +42 -8
- package/dist/worker/console/human-gate-token.js +130 -0
- package/dist/worker/console/mutation-gate-receipt-store.js +184 -0
- package/dist/worker/console/operation-runner.js +6 -2
- package/dist/worker/console/operation-sse.js +26 -0
- package/dist/worker/console/operator-actions.js +420 -7
- package/dist/worker/console/server.js +68 -1
- package/dist/worker/console/static/assets/index-BTbrEHnO.css +1 -0
- package/dist/worker/console/static/assets/index-D9qLevoP.js +27 -0
- package/dist/worker/console/static/index.html +2 -2
- package/dist/worker/feature/profile-schema.js +1 -1
- package/dist/workflows/dag/backend-test-markdown-workflow.js +9 -5
- package/dist/workflows/dag/backend-test-result-contract.js +229 -0
- package/dist/workflows/dag/frontend-lint-baseline.js +4 -4
- package/dist/workflows/dag/init-hybrid.js +71 -22
- package/dist/workflows/dag/node-execution.js +38 -1
- package/dist/workflows/dag/output-protocol.js +89 -0
- package/dist/workflows/dag/prompt.js +35 -1
- package/dist/workflows/dag/recovery-recommendation.js +45 -0
- package/dist/workflows/dag/report.js +28 -1
- package/dist/workflows/dag/rerun-task.js +1 -1
- package/dist/workflows/dag/scheduler.js +9 -0
- package/dist/workflows/dag/types.js +12 -0
- package/dist/workflows/dag/validate.js +55 -0
- package/docs/README.md +73 -156
- package/docs/architecture/README.md +7 -6
- package/docs/architecture/dag-execution.md +2 -2
- package/docs/architecture/evolution.md +16 -14
- package/docs/architecture/system-overview.md +1 -1
- package/docs/architecture/worker-and-feature.md +3 -3
- package/docs/governance/README.md +15 -0
- package/docs/{harness-methodology-debugging.md → governance/harness-methodology-debugging.md} +27 -3
- package/docs/init-surface.manifest.json +22 -4
- package/docs/operations/README.md +12 -0
- package/docs/{local-development-environment.md → operations/local-development-environment.md} +1 -1
- package/docs/skills/vetted-skill-registry.md +23 -3
- package/docs/templates/README.md +55 -0
- package/docs/templates/backend-test-dag.json +2 -2
- package/docs/templates/evaluation/agents-map-slim-v1.candidate.json +9 -0
- package/docs/templates/evaluation/agents-map-slim-v1.md +87 -0
- package/docs/templates/evaluation/agents-map-verbose-v0.candidate.json +9 -0
- package/docs/templates/evaluation/agents-map-verbose-v0.md +153 -0
- package/docs/templates/hybrid-dag.json +1 -1
- package/docs/templates/progress-log.md +9 -2
- package/harness.json +4 -4
- package/package.json +5 -5
- package/scripts/kb-bootstrap-init-skeleton.sh +2 -2
- package/skills/agent-worker/SKILL.md +1 -1
- package/skills/grill-with-docs/SKILL.md +44 -52
- package/skills/grill-with-docs/adr-format.md +37 -26
- package/skills/grill-with-docs/context-format.md +18 -26
- package/skills/loop-agent/SKILL.md +28 -112
- package/skills/loop-agent/references/command-reference.md +9 -3
- package/skills/loop-agent/references/harness-policy.md +3 -3
- package/skills/loop-agent/references/hybrid-dag.md +1 -1
- package/skills/loop-agent/references/orchestrator-and-interventions.md +1 -1
- package/skills/loop-agent/references/task-workflow.md +2 -0
- package/skills/systematic-debugging/SKILL.md +20 -4
- package/skills/test-driven-development/SKILL.md +10 -3
- package/dist/worker/console/static/assets/index-CUDke82y.js +0 -18
- package/dist/worker/console/static/assets/index-wSEksVSO.css +0 -1
- /package/docs/{harness-methodology-tdd.md → governance/harness-methodology-tdd.md} +0 -0
- /package/docs/{harness-methodology-verification.md → governance/harness-methodology-verification.md} +0 -0
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract-apply Human Gate receipts with atomic prepared → dispatching →
|
|
3
|
+
* dispatched consumption so concurrent confirm POSTs cannot create duplicate
|
|
4
|
+
* operations.
|
|
5
|
+
*/
|
|
6
|
+
import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
export class ContractApplyReceiptStore {
|
|
9
|
+
dir;
|
|
10
|
+
constructor(appData) {
|
|
11
|
+
this.dir = path.join(appData.root, "contract-apply-receipts");
|
|
12
|
+
}
|
|
13
|
+
file(id) {
|
|
14
|
+
return path.join(this.dir, `${id.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`);
|
|
15
|
+
}
|
|
16
|
+
async prepare(input) {
|
|
17
|
+
const receipt = {
|
|
18
|
+
schemaVersion: 1,
|
|
19
|
+
state: "prepared",
|
|
20
|
+
...input,
|
|
21
|
+
};
|
|
22
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
23
|
+
await writeFile(this.file(receipt.receiptId), `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
|
|
24
|
+
return receipt;
|
|
25
|
+
}
|
|
26
|
+
async get(id) {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(await readFile(this.file(id), "utf8"));
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (error.code === "ENOENT")
|
|
32
|
+
return undefined;
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async tryReserve(id) {
|
|
37
|
+
const receipt = await this.get(id);
|
|
38
|
+
if (!receipt) {
|
|
39
|
+
return {
|
|
40
|
+
ok: false,
|
|
41
|
+
code: "NOT_FOUND",
|
|
42
|
+
message: `receipt not found: ${id}`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (Date.parse(receipt.expiresAt) <= Date.now()) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
code: "EXPIRED",
|
|
49
|
+
message: "contract apply receipt expired",
|
|
50
|
+
receipt,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (receipt.state === "dispatching") {
|
|
54
|
+
return {
|
|
55
|
+
ok: true,
|
|
56
|
+
receipt,
|
|
57
|
+
reserved: false,
|
|
58
|
+
reason: "already-dispatching",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (receipt.state === "dispatched") {
|
|
62
|
+
return {
|
|
63
|
+
ok: true,
|
|
64
|
+
receipt,
|
|
65
|
+
reserved: false,
|
|
66
|
+
reason: "already-dispatched",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (receipt.state !== "prepared") {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
code: "INVALID_STATE",
|
|
73
|
+
message: `receipt is ${receipt.state}`,
|
|
74
|
+
receipt,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const next = {
|
|
78
|
+
...receipt,
|
|
79
|
+
state: "dispatching",
|
|
80
|
+
reservedAt: new Date().toISOString(),
|
|
81
|
+
};
|
|
82
|
+
const claimed = await this.compareAndSwap(id, "prepared", next);
|
|
83
|
+
if (!claimed.ok) {
|
|
84
|
+
const current = claimed.current;
|
|
85
|
+
if (current?.state === "dispatching") {
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
receipt: current,
|
|
89
|
+
reserved: false,
|
|
90
|
+
reason: "already-dispatching",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (current?.state === "dispatched") {
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
receipt: current,
|
|
97
|
+
reserved: false,
|
|
98
|
+
reason: "already-dispatched",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
ok: false,
|
|
103
|
+
code: "INVALID_STATE",
|
|
104
|
+
message: `receipt raced into ${current?.state ?? "missing"}`,
|
|
105
|
+
receipt: current,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return { ok: true, receipt: next, reserved: true };
|
|
109
|
+
}
|
|
110
|
+
async markDispatched(id, operationId) {
|
|
111
|
+
const receipt = await this.get(id);
|
|
112
|
+
if (!receipt)
|
|
113
|
+
throw new Error(`receipt not found: ${id}`);
|
|
114
|
+
const next = {
|
|
115
|
+
...receipt,
|
|
116
|
+
state: "dispatched",
|
|
117
|
+
...(operationId ? { operationId } : {}),
|
|
118
|
+
dispatchedAt: new Date().toISOString(),
|
|
119
|
+
};
|
|
120
|
+
await writeFile(this.file(id), `${JSON.stringify(next, null, 2)}\n`, {
|
|
121
|
+
mode: 0o600,
|
|
122
|
+
});
|
|
123
|
+
return next;
|
|
124
|
+
}
|
|
125
|
+
async releaseToPrepared(id) {
|
|
126
|
+
const receipt = await this.get(id);
|
|
127
|
+
if (!receipt || receipt.state !== "dispatching")
|
|
128
|
+
return receipt;
|
|
129
|
+
const next = {
|
|
130
|
+
...receipt,
|
|
131
|
+
state: "prepared",
|
|
132
|
+
reservedAt: undefined,
|
|
133
|
+
};
|
|
134
|
+
await writeFile(this.file(id), `${JSON.stringify(next, null, 2)}\n`, {
|
|
135
|
+
mode: 0o600,
|
|
136
|
+
});
|
|
137
|
+
return next;
|
|
138
|
+
}
|
|
139
|
+
async compareAndSwap(id, expectedState, next) {
|
|
140
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
141
|
+
const target = this.file(id);
|
|
142
|
+
const lockPath = `${target}.lock`;
|
|
143
|
+
try {
|
|
144
|
+
await writeFile(lockPath, `${process.pid}\n`, { flag: "wx", mode: 0o600 });
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
if (error.code === "EEXIST") {
|
|
148
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
149
|
+
return { ok: false, current: await this.get(id) };
|
|
150
|
+
}
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const current = await this.get(id);
|
|
155
|
+
if (!current || current.state !== expectedState) {
|
|
156
|
+
return { ok: false, current };
|
|
157
|
+
}
|
|
158
|
+
const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
|
|
159
|
+
await writeFile(tmp, `${JSON.stringify(next, null, 2)}\n`, {
|
|
160
|
+
mode: 0o600,
|
|
161
|
+
});
|
|
162
|
+
await rename(tmp, target);
|
|
163
|
+
return { ok: true };
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
try {
|
|
167
|
+
await unlink(lockPath);
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
// lock cleanup is best-effort
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator Chat — safe read-only explore tools (roadmap M0-A / G16).
|
|
3
|
+
*
|
|
4
|
+
* Replaces the bare SDK built-in bash/read/grep with a typed, boundary-
|
|
5
|
+
* enforced read-only surface. The model can still probe the repo, but:
|
|
6
|
+
*
|
|
7
|
+
* - No shell escapes (no `echo >`, `tee`, `sed -i`, `node -e`, pipes to
|
|
8
|
+
* shell, redirects, subshells). The write-via-redirect residual is gone.
|
|
9
|
+
* - read/grep enforce a repo-relative path boundary (no `..`, no absolute
|
|
10
|
+
* paths outside repo, no symlink escape) and a sensitive-file denylist
|
|
11
|
+
* (env files, key/pem, .git internals, Pi auth.json, sessions dir,
|
|
12
|
+
* harness dag-run session facts).
|
|
13
|
+
* - All returned content is run through secret scrubbing so API keys, OAuth
|
|
14
|
+
* tokens and private keys never reach the model context (prompt injection
|
|
15
|
+
* / credential leak defense — roadmap §3.2 read-face gap, G16).
|
|
16
|
+
* - gitStatus / gitDiff are typed read-only git probes (no `git` shell).
|
|
17
|
+
*
|
|
18
|
+
* These are registered as Pi customTools alongside the operator-action tools.
|
|
19
|
+
* They are NOT operator actions (not in the capabilities registry) — they are
|
|
20
|
+
* the Chat's built-in explore surface, parallel to find/ls.
|
|
21
|
+
*/
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { open, readFile, realpath } from "node:fs/promises";
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
/** Repo-relative path patterns that are ALWAYS denied to read/grep (G16). */
|
|
26
|
+
export const SENSITIVE_PATH_PATTERNS = [
|
|
27
|
+
// Environment / secrets
|
|
28
|
+
/(^|\/)\.env(\.|$)/i,
|
|
29
|
+
/(^|\/)\.env\.[a-z0-9_-]+$/i,
|
|
30
|
+
/\.key$/i,
|
|
31
|
+
/\.pem$/i,
|
|
32
|
+
/\.pfx$/i,
|
|
33
|
+
/\.keystore$/i,
|
|
34
|
+
// Git internals
|
|
35
|
+
/(^|\/)\.git\//i,
|
|
36
|
+
// Pi credential / session store (user-level + project-level + console app-data)
|
|
37
|
+
/(^|\/)auth\.json$/i,
|
|
38
|
+
/(^|\/)sessions\//i,
|
|
39
|
+
// DAG run-owned session facts (runtime internals, not for model eyes)
|
|
40
|
+
/(^|\/)\.harness\/dag-runs\/.*\/session\b/i,
|
|
41
|
+
// Generic credential filenames
|
|
42
|
+
/(^|\/)(credentials|secrets)\.json$/i,
|
|
43
|
+
/(^|\/)id_rsa/i,
|
|
44
|
+
/(^|\/)id_ecdsa/i,
|
|
45
|
+
/(^|\/)id_ed25519/i,
|
|
46
|
+
];
|
|
47
|
+
/** Secret-like patterns scrubbed from any tool result content (G16). */
|
|
48
|
+
export const SECRET_SCRUB_PATTERNS = [
|
|
49
|
+
// AWS access key id
|
|
50
|
+
{ name: "aws-access-key-id", re: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
51
|
+
// AWS secret access key (40 char base64-ish after the key id context)
|
|
52
|
+
{
|
|
53
|
+
name: "aws-secret",
|
|
54
|
+
re: /\b(?:aws_secret_access_key|secret_access_key)["'\s:=]+([A-Za-z0-9/+=]{40})\b/gi,
|
|
55
|
+
},
|
|
56
|
+
// Google API key / OAuth
|
|
57
|
+
{ name: "google-api-key", re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
|
|
58
|
+
// Generic API key assignments (header style)
|
|
59
|
+
{
|
|
60
|
+
name: "api-key-assign",
|
|
61
|
+
re: /\b(?:api[_-]?key|apikey|x-api-key)["'\s:=]+([A-Za-z0-9_-]{20,})/gi,
|
|
62
|
+
},
|
|
63
|
+
// Bearer tokens
|
|
64
|
+
{ name: "bearer-token", re: /\bBearer\s+[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g },
|
|
65
|
+
// GitHub tokens
|
|
66
|
+
{ name: "github-token", re: /\b(?:gh[pousr]_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{22}_[A-Za-z0-9]{59})\b/g },
|
|
67
|
+
// Slack tokens
|
|
68
|
+
{ name: "slack-token", re: /\bxox[baprs]-[A-Za-z0-9-]+\b/g },
|
|
69
|
+
// Private key blocks (PEM)
|
|
70
|
+
{
|
|
71
|
+
name: "private-key-block",
|
|
72
|
+
re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |)PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH |DSA |)PRIVATE KEY-----/g,
|
|
73
|
+
},
|
|
74
|
+
];
|
|
75
|
+
/** Throw a typed explore error (kept as a value object so callers can read .code). */
|
|
76
|
+
export class ExploreDeniedError extends Error {
|
|
77
|
+
code;
|
|
78
|
+
ok = false;
|
|
79
|
+
constructor(code, message) {
|
|
80
|
+
super(message);
|
|
81
|
+
this.code = code;
|
|
82
|
+
this.name = "ExploreDeniedError";
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Replace secret-like substrings with a redaction marker. Returns the scrubbed
|
|
87
|
+
* text plus a tally (no secret values). Used by safe-read / safe-grep before
|
|
88
|
+
* any content reaches the model context.
|
|
89
|
+
*/
|
|
90
|
+
export function scrubSecrets(input) {
|
|
91
|
+
let out = input;
|
|
92
|
+
const redactions = [];
|
|
93
|
+
for (const { name, re } of SECRET_SCRUB_PATTERNS) {
|
|
94
|
+
re.lastIndex = 0;
|
|
95
|
+
const matches = out.match(re);
|
|
96
|
+
if (matches && matches.length > 0) {
|
|
97
|
+
out = out.replace(re, `[REDACTED:${name}]`);
|
|
98
|
+
redactions.push({ name, count: matches.length });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return { scrubbed: out, redactions };
|
|
102
|
+
}
|
|
103
|
+
/** True if a repo-relative path matches a sensitive-file denylist pattern. */
|
|
104
|
+
export function isSensitivePath(repoRelative) {
|
|
105
|
+
const normalized = repoRelative.replace(/\\/g, "/").replace(/^\.\/+/, "");
|
|
106
|
+
for (const re of SENSITIVE_PATH_PATTERNS) {
|
|
107
|
+
if (re.test(normalized))
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Resolve a model-supplied path against the repo root and enforce:
|
|
114
|
+
* - repo-relative only (no absolute paths, no `..` escape, no symlink escape
|
|
115
|
+
* outside repo via realpath),
|
|
116
|
+
* - sensitive-file denylist.
|
|
117
|
+
*/
|
|
118
|
+
export async function guardReadPath(repoRoot, rawPath) {
|
|
119
|
+
if (!rawPath || typeof rawPath !== "string") {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
code: "PATH_OUTSIDE_REPO",
|
|
123
|
+
message: "path is required",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
// Reject absolute paths (Windows drive letters too) — model must use repo-relative.
|
|
127
|
+
if (path.isAbsolute(rawPath)) {
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
code: "PATH_OUTSIDE_REPO",
|
|
131
|
+
message: `absolute paths are not allowed; use a repo-relative path (got ${rawPath})`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
// Reject `..` segments outright (defense before realpath).
|
|
135
|
+
if (rawPath.split(/[\\/]/).some((seg) => seg === "..")) {
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
code: "PATH_OUTSIDE_REPO",
|
|
139
|
+
message: `path traversal (..) is not allowed: ${rawPath}`,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const normalizedRepoRelative = rawPath.replace(/\\/g, "/").replace(/^\.\/+/, "");
|
|
143
|
+
if (isSensitivePath(normalizedRepoRelative)) {
|
|
144
|
+
return {
|
|
145
|
+
ok: false,
|
|
146
|
+
code: "SENSITIVE_PATH_DENIED",
|
|
147
|
+
message: `path "${normalizedRepoRelative}" matches a sensitive-file denylist (.env* / *.key / *.pem / .git/** / auth.json / sessions/** / dag-runs session); reading is denied to protect credentials`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const abs = path.resolve(repoRoot, normalizedRepoRelative);
|
|
151
|
+
// Lexical containment rejects traversal before touching the filesystem.
|
|
152
|
+
const rel = path.relative(repoRoot, abs);
|
|
153
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
code: "PATH_OUTSIDE_REPO",
|
|
157
|
+
message: `resolved path escapes repo root: ${rawPath}`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
// path.resolve() alone does not stop `repo/link -> /private/file`: readFile
|
|
161
|
+
// follows the link and would expose the target outside the repo. Resolve both
|
|
162
|
+
// ends when the candidate exists, then repeat the containment and denylist
|
|
163
|
+
// checks against its canonical location. A nonexistent candidate remains
|
|
164
|
+
// lexically valid so callers such as git-diff can still ask Git about it;
|
|
165
|
+
// safeReadFile will subsequently report its normal read failure.
|
|
166
|
+
try {
|
|
167
|
+
const [realRepoRoot, realCandidate] = await Promise.all([
|
|
168
|
+
realpath(repoRoot),
|
|
169
|
+
realpath(abs),
|
|
170
|
+
]);
|
|
171
|
+
const realRelative = path.relative(realRepoRoot, realCandidate);
|
|
172
|
+
if (realRelative.startsWith("..") || path.isAbsolute(realRelative)) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
code: "PATH_OUTSIDE_REPO",
|
|
176
|
+
message: `symlink target escapes repo root: ${rawPath}`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (isSensitivePath(realRelative.replace(/\\/g, "/"))) {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
code: "SENSITIVE_PATH_DENIED",
|
|
183
|
+
message: `symlink target is a sensitive path: ${rawPath}`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return { ok: true, abs, repoRelative: normalizedRepoRelative };
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return { ok: true, abs, repoRelative: normalizedRepoRelative };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/** Cap read size so the model context cannot be flooded (G12). */
|
|
193
|
+
export const READ_BYTE_LIMIT = 256 * 1024; // 256 KiB
|
|
194
|
+
/**
|
|
195
|
+
* Read a repo-relative file with full M0-A guards. Throws a typed error
|
|
196
|
+
* object (code/message) on denial so the customTool wrapper can surface it.
|
|
197
|
+
*/
|
|
198
|
+
export async function safeReadFile(repoRoot, rawPath) {
|
|
199
|
+
const guard = await guardReadPath(repoRoot, rawPath);
|
|
200
|
+
if (!guard.ok) {
|
|
201
|
+
throw new ExploreDeniedError(guard.code, guard.message);
|
|
202
|
+
}
|
|
203
|
+
// Do not call readFile() here: it allocates the entire file before the
|
|
204
|
+
// truncation check, so an oversized repo file could exhaust the Console
|
|
205
|
+
// process even though the response is bounded. Read at most the public cap.
|
|
206
|
+
const handle = await open(guard.abs, "r");
|
|
207
|
+
try {
|
|
208
|
+
const info = await handle.stat();
|
|
209
|
+
if (!info.isFile()) {
|
|
210
|
+
throw new ExploreDeniedError("READ_FAILED", `not a regular file: ${guard.repoRelative}`);
|
|
211
|
+
}
|
|
212
|
+
const bytes = info.size;
|
|
213
|
+
const bytesToRead = Math.min(bytes, READ_BYTE_LIMIT);
|
|
214
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
215
|
+
const bytesRead = bytesToRead > 0
|
|
216
|
+
? (await handle.read(buffer, 0, bytesToRead, 0)).bytesRead
|
|
217
|
+
: 0;
|
|
218
|
+
const { scrubbed, redactions } = scrubSecrets(buffer.subarray(0, bytesRead).toString("utf8"));
|
|
219
|
+
return {
|
|
220
|
+
path: guard.repoRelative,
|
|
221
|
+
bytes,
|
|
222
|
+
truncated: bytes > READ_BYTE_LIMIT,
|
|
223
|
+
redactions,
|
|
224
|
+
content: scrubbed,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
await handle.close();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
export const GREP_MATCH_LIMIT = 100;
|
|
232
|
+
/**
|
|
233
|
+
* Repo-bounded, secret-scrubbed line search. The pattern is a plain string or
|
|
234
|
+
* regex source (no shell, no flags beyond `i`). Scans files under repoRoot,
|
|
235
|
+
* skipping the sensitive denylist and binary-looking files.
|
|
236
|
+
*/
|
|
237
|
+
export async function safeGrep(repoRoot, pattern, options) {
|
|
238
|
+
if (!pattern) {
|
|
239
|
+
throw new ExploreDeniedError("INVALID_INPUT", "pattern is required");
|
|
240
|
+
}
|
|
241
|
+
let re;
|
|
242
|
+
try {
|
|
243
|
+
re = new RegExp(pattern, options?.caseInsensitive ? "i" : undefined);
|
|
244
|
+
}
|
|
245
|
+
catch (e) {
|
|
246
|
+
throw new ExploreDeniedError("INVALID_INPUT", `invalid regex pattern: ${e instanceof Error ? e.message : String(e)}`);
|
|
247
|
+
}
|
|
248
|
+
// Dynamic import to keep the explore module free of a hard chokidar dep at
|
|
249
|
+
// module load (rg is not guaranteed cross-platform; use node fs walk).
|
|
250
|
+
const { walkRepoFiles } = await import("./repo-walk.js");
|
|
251
|
+
const files = await walkRepoFiles(repoRoot, {
|
|
252
|
+
glob: options?.glob,
|
|
253
|
+
skipSensitive: true,
|
|
254
|
+
});
|
|
255
|
+
const matches = [];
|
|
256
|
+
let totalRedactions = [];
|
|
257
|
+
for (const file of files) {
|
|
258
|
+
if (matches.length >= GREP_MATCH_LIMIT)
|
|
259
|
+
break;
|
|
260
|
+
try {
|
|
261
|
+
const buf = await readFile(file.abs);
|
|
262
|
+
if (buf.byteLength > READ_BYTE_LIMIT)
|
|
263
|
+
continue;
|
|
264
|
+
const text = buf.toString("utf8");
|
|
265
|
+
const lines = text.split(/\r?\n/);
|
|
266
|
+
for (let i = 0; i < lines.length; i++) {
|
|
267
|
+
if (matches.length >= GREP_MATCH_LIMIT)
|
|
268
|
+
break;
|
|
269
|
+
if (re.test(lines[i])) {
|
|
270
|
+
const { scrubbed, redactions } = scrubSecrets(lines[i]);
|
|
271
|
+
if (redactions.length > 0) {
|
|
272
|
+
for (const r of redactions) {
|
|
273
|
+
const found = totalRedactions.find((t) => t.name === r.name);
|
|
274
|
+
if (found)
|
|
275
|
+
found.count += r.count;
|
|
276
|
+
else
|
|
277
|
+
totalRedactions.push({ ...r });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
matches.push({ path: file.repoRelative, line: i + 1, text: scrubbed });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// skip unreadable / binary files
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return {
|
|
289
|
+
pattern,
|
|
290
|
+
matchCount: matches.length,
|
|
291
|
+
truncated: matches.length >= GREP_MATCH_LIMIT,
|
|
292
|
+
redactions: totalRedactions,
|
|
293
|
+
matches,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
/** A stable, denoised fingerprint of the repo root (for hashing). */
|
|
297
|
+
export function repoRootKey(repoRoot) {
|
|
298
|
+
return createHash("sha256").update(path.resolve(repoRoot)).digest("hex").slice(0, 16);
|
|
299
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export function makeHumanGateCard(input) {
|
|
2
|
+
const canonical = JSON.stringify({
|
|
3
|
+
cardId: input.cardId,
|
|
4
|
+
gateType: input.gateType,
|
|
5
|
+
state: input.state,
|
|
6
|
+
taskId: input.taskId,
|
|
7
|
+
confirmationId: input.confirmationId,
|
|
8
|
+
receiptId: input.receiptId,
|
|
9
|
+
contractRevision: input.contractRevision,
|
|
10
|
+
dagSpine: input.dagSpine,
|
|
11
|
+
});
|
|
12
|
+
let hash = 2166136261;
|
|
13
|
+
for (let i = 0; i < canonical.length; i += 1)
|
|
14
|
+
hash = Math.imul(hash ^ canonical.charCodeAt(i), 16777619);
|
|
15
|
+
const previewHash = `fnv1a-${(hash >>> 0).toString(16).padStart(8, "0")}`;
|
|
16
|
+
return { schemaVersion: 1, ...input, previewHash };
|
|
17
|
+
}
|
|
18
|
+
export function humanGateCardFromEventPayload(value) {
|
|
19
|
+
if (!value || typeof value !== "object")
|
|
20
|
+
return undefined;
|
|
21
|
+
const card = value;
|
|
22
|
+
if (card.schemaVersion !== 1 || typeof card.cardId !== "string" ||
|
|
23
|
+
(card.gateType !== "contract-apply" && card.gateType !== "dag-run") ||
|
|
24
|
+
typeof card.taskId !== "string" || typeof card.state !== "string" ||
|
|
25
|
+
typeof card.expiresAt !== "string" || typeof card.inspectHref !== "string" ||
|
|
26
|
+
typeof card.previewHash !== "string")
|
|
27
|
+
return undefined;
|
|
28
|
+
return card;
|
|
29
|
+
}
|
|
30
|
+
export function mergeHumanGateCardState(cards, next) {
|
|
31
|
+
const index = cards.findIndex((card) => card.cardId === next.cardId);
|
|
32
|
+
if (index < 0)
|
|
33
|
+
return [...cards, next];
|
|
34
|
+
const copy = [...cards];
|
|
35
|
+
copy[index] = next;
|
|
36
|
+
return copy;
|
|
37
|
+
}
|