cohorte 2.10.1 → 3.0.0-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -0
- package/assets/manifest.json +193 -0
- package/assets/migrations/config/README.md +12 -0
- package/assets/migrations/state/0001_init.sql +154 -0
- package/assets/prompts/README.md +11 -0
- package/assets/prompts/agents/fixer.md +7 -0
- package/assets/prompts/agents/implementer.md +12 -0
- package/assets/prompts/agents/reviewer.md +8 -0
- package/assets/prompts/agents/security-reviewer.md +7 -0
- package/assets/prompts/phases/build.md +5 -0
- package/assets/prompts/phases/review.md +5 -0
- package/assets/prompts/phases/test.md +5 -0
- package/assets/prompts/system.md +11 -0
- package/assets/schemas/agent-output.schema.json +225 -0
- package/assets/schemas/auth-status.schema.json +87 -0
- package/assets/schemas/command-result.schema.json +192 -0
- package/assets/schemas/commands.schema.json +952 -0
- package/assets/schemas/config.schema.json +2259 -0
- package/assets/schemas/doctor-report.schema.json +104 -0
- package/assets/schemas/events.schema.json +8514 -0
- package/assets/schemas/fake-script.schema.json +549 -0
- package/assets/schemas/inspect.schema.json +3133 -0
- package/assets/schemas/manifest.schema.json +79 -0
- package/assets/schemas/ownership.schema.json +52 -0
- package/assets/schemas/policy-verdict.schema.json +282 -0
- package/assets/schemas/project-model.schema.json +783 -0
- package/assets/schemas/project-status.schema.json +217 -0
- package/assets/schemas/reconcile-plan.schema.json +154 -0
- package/assets/schemas/run-diff.schema.json +177 -0
- package/assets/schemas/run-snapshot-manifest.schema.json +2104 -0
- package/assets/schemas/run-state.schema.json +1755 -0
- package/assets/schemas/runtime-capabilities.schema.json +1545 -0
- package/assets/schemas/sandbox-capabilities.schema.json +116 -0
- package/assets/schemas/skill.schema.json +87 -0
- package/assets/schemas/spec.schema.json +202 -0
- package/assets/schemas/tool-catalogue.schema.json +655 -0
- package/assets/schemas/trust-record.schema.json +38 -0
- package/assets/skills/README.md +9 -0
- package/assets/skills/inspect-surface.md +4 -0
- package/dist/agent-host.mjs +2187 -0
- package/dist/agent-host.mjs.map +1 -0
- package/dist/bundle-manifest.json +104 -0
- package/dist/chunks/__host-DgGPyco9.mjs +18 -0
- package/dist/chunks/__host-DgGPyco9.mjs.map +1 -0
- package/dist/chunks/approve-Cv135UqI.mjs +19 -0
- package/dist/chunks/approve-Cv135UqI.mjs.map +1 -0
- package/dist/chunks/auth-BT5oVp76.mjs +132 -0
- package/dist/chunks/auth-BT5oVp76.mjs.map +1 -0
- package/dist/chunks/auth-CYOh60KG.mjs +93 -0
- package/dist/chunks/auth-CYOh60KG.mjs.map +1 -0
- package/dist/chunks/brainstorm-BwKA6yZP.mjs +7 -0
- package/dist/chunks/brainstorm-BwKA6yZP.mjs.map +1 -0
- package/dist/chunks/cancel-KV_cpsqS.mjs +15 -0
- package/dist/chunks/cancel-KV_cpsqS.mjs.map +1 -0
- package/dist/chunks/canonical-C8UxeEmB.mjs +57 -0
- package/dist/chunks/canonical-C8UxeEmB.mjs.map +1 -0
- package/dist/chunks/catalogue-BvwSrM_j.mjs +1549 -0
- package/dist/chunks/catalogue-BvwSrM_j.mjs.map +1 -0
- package/dist/chunks/compose-Dvxunpto.mjs +7307 -0
- package/dist/chunks/compose-Dvxunpto.mjs.map +1 -0
- package/dist/chunks/config-DefHA2Lw.mjs +96 -0
- package/dist/chunks/config-DefHA2Lw.mjs.map +1 -0
- package/dist/chunks/contract-BkMjU1kR.mjs +295 -0
- package/dist/chunks/contract-BkMjU1kR.mjs.map +1 -0
- package/dist/chunks/contract-bQ9FJl4N.mjs +123 -0
- package/dist/chunks/contract-bQ9FJl4N.mjs.map +1 -0
- package/dist/chunks/deny-ClTxSvHJ.mjs +15 -0
- package/dist/chunks/deny-ClTxSvHJ.mjs.map +1 -0
- package/dist/chunks/diff-Qw0II0sj.mjs +101 -0
- package/dist/chunks/diff-Qw0II0sj.mjs.map +1 -0
- package/dist/chunks/discover-eS01-me1.mjs +21 -0
- package/dist/chunks/discover-eS01-me1.mjs.map +1 -0
- package/dist/chunks/doctor-B-H2UK5e.mjs +222 -0
- package/dist/chunks/doctor-B-H2UK5e.mjs.map +1 -0
- package/dist/chunks/fix-D4Tj8RVA.mjs +18 -0
- package/dist/chunks/fix-D4Tj8RVA.mjs.map +1 -0
- package/dist/chunks/gc-BBXlBF5w.mjs +161 -0
- package/dist/chunks/gc-BBXlBF5w.mjs.map +1 -0
- package/dist/chunks/init-BUnzIkeE.mjs +63 -0
- package/dist/chunks/init-BUnzIkeE.mjs.map +1 -0
- package/dist/chunks/inspect-Buby4cQD.mjs +22 -0
- package/dist/chunks/inspect-Buby4cQD.mjs.map +1 -0
- package/dist/chunks/logs-Giq5sUau.mjs +25 -0
- package/dist/chunks/logs-Giq5sUau.mjs.map +1 -0
- package/dist/chunks/migrate-DybihKzZ.mjs +33 -0
- package/dist/chunks/migrate-DybihKzZ.mjs.map +1 -0
- package/dist/chunks/models-Btp6X2YZ.mjs +24 -0
- package/dist/chunks/models-Btp6X2YZ.mjs.map +1 -0
- package/dist/chunks/pause-BASBfKLO.mjs +16 -0
- package/dist/chunks/pause-BASBfKLO.mjs.map +1 -0
- package/dist/chunks/policy-DUIgzF3W.mjs +139 -0
- package/dist/chunks/policy-DUIgzF3W.mjs.map +1 -0
- package/dist/chunks/providers-Dx78imKj.mjs +19 -0
- package/dist/chunks/providers-Dx78imKj.mjs.map +1 -0
- package/dist/chunks/reconcile-Bhy3IIYo.mjs +50 -0
- package/dist/chunks/reconcile-Bhy3IIYo.mjs.map +1 -0
- package/dist/chunks/render-DyZr5Lgi.mjs +53 -0
- package/dist/chunks/render-DyZr5Lgi.mjs.map +1 -0
- package/dist/chunks/resume-GJQRb2Eb.mjs +15 -0
- package/dist/chunks/resume-GJQRb2Eb.mjs.map +1 -0
- package/dist/chunks/retry-CLrUem1H.mjs +15 -0
- package/dist/chunks/retry-CLrUem1H.mjs.map +1 -0
- package/dist/chunks/review-BDdjb6kB.mjs +17 -0
- package/dist/chunks/review-BDdjb6kB.mjs.map +1 -0
- package/dist/chunks/run-CgTWYlcd.mjs +82 -0
- package/dist/chunks/run-CgTWYlcd.mjs.map +1 -0
- package/dist/chunks/run-tool-C3rAv4XF.mjs +26 -0
- package/dist/chunks/run-tool-C3rAv4XF.mjs.map +1 -0
- package/dist/chunks/scan-8Gy_B6kI.mjs +122 -0
- package/dist/chunks/scan-8Gy_B6kI.mjs.map +1 -0
- package/dist/chunks/schema-C9QbUW4H.mjs +578 -0
- package/dist/chunks/schema-C9QbUW4H.mjs.map +1 -0
- package/dist/chunks/send-BdnJ85Nz.mjs +19 -0
- package/dist/chunks/send-BdnJ85Nz.mjs.map +1 -0
- package/dist/chunks/ship-DTuLGoaJ.mjs +19 -0
- package/dist/chunks/ship-DTuLGoaJ.mjs.map +1 -0
- package/dist/chunks/shutdown-BM92Iyv2.mjs +15 -0
- package/dist/chunks/shutdown-BM92Iyv2.mjs.map +1 -0
- package/dist/chunks/skip-DZG5kpcH.mjs +18 -0
- package/dist/chunks/skip-DZG5kpcH.mjs.map +1 -0
- package/dist/chunks/spec-Bq0P3jVh.mjs +20 -0
- package/dist/chunks/spec-Bq0P3jVh.mjs.map +1 -0
- package/dist/chunks/src-CId6kMsY.mjs +1689 -0
- package/dist/chunks/src-CId6kMsY.mjs.map +1 -0
- package/dist/chunks/src-CfAOde7C.mjs +5403 -0
- package/dist/chunks/src-CfAOde7C.mjs.map +1 -0
- package/dist/chunks/src-CmFjh4M0.mjs +928 -0
- package/dist/chunks/src-CmFjh4M0.mjs.map +1 -0
- package/dist/chunks/src-Cu3TxNob.mjs +783 -0
- package/dist/chunks/src-Cu3TxNob.mjs.map +1 -0
- package/dist/chunks/src-DcU4IXMe.mjs +117 -0
- package/dist/chunks/src-DcU4IXMe.mjs.map +1 -0
- package/dist/chunks/src-s7YRvk0t.mjs +2116 -0
- package/dist/chunks/src-s7YRvk0t.mjs.map +1 -0
- package/dist/chunks/status-SMjiOMYy.mjs +29 -0
- package/dist/chunks/status-SMjiOMYy.mjs.map +1 -0
- package/dist/chunks/tail-BuhT-2PH.mjs +25 -0
- package/dist/chunks/tail-BuhT-2PH.mjs.map +1 -0
- package/dist/chunks/update-BXIppsbi.mjs +16 -0
- package/dist/chunks/update-BXIppsbi.mjs.map +1 -0
- package/dist/cli.mjs +175 -0
- package/dist/cli.mjs.map +1 -0
- package/package.json +17 -32
- package/CHANGELOG.md +0 -1461
- package/bin/cli.js +0 -817
- package/bin/report.js +0 -141
- package/core/adapter/render.js +0 -415
- package/core/agents/implementer.template.md +0 -142
- package/core/agents/profile-reader.md +0 -50
- package/core/agents/release.md +0 -58
- package/core/agents/review.md +0 -179
- package/core/commands/cohorte-align-ds.md +0 -33
- package/core/commands/cohorte-audit.md +0 -58
- package/core/commands/cohorte-brainstorm.md +0 -70
- package/core/commands/cohorte-build.md +0 -219
- package/core/commands/cohorte-doctor.md +0 -194
- package/core/commands/cohorte-fix.md +0 -80
- package/core/commands/cohorte-fleet.md +0 -103
- package/core/commands/cohorte-init-pipeline.md +0 -31
- package/core/commands/cohorte-intake.md +0 -92
- package/core/commands/cohorte-patch.md +0 -118
- package/core/commands/cohorte-refactor.md +0 -59
- package/core/commands/cohorte-retro.md +0 -85
- package/core/commands/cohorte-review.md +0 -270
- package/core/commands/cohorte-ship.md +0 -122
- package/core/commands/cohorte-spec.md +0 -101
- package/core/commands/cohorte-update-pipeline.md +0 -165
- package/core/hooks/gate.py +0 -588
- package/core/runtimes/claude.json +0 -74
- package/core/runtimes/codex.json +0 -85
- package/core/runtimes/cursor.json +0 -76
- package/core/runtimes/gemini.json +0 -76
- package/core/runtimes/opencode.json +0 -73
- package/core/templates/agent-handoff.md +0 -34
- package/core/templates/brainstorm-return.md +0 -36
- package/core/templates/decisions.template.md +0 -42
- package/core/templates/design-brief.md +0 -44
- package/core/templates/patch.template.md +0 -86
- package/core/templates/pr-body.md +0 -25
- package/core/templates/review-feedback.md +0 -38
- package/core/templates/spec.template.md +0 -87
- package/core/templates/steps/init-pipeline/01-detect-stack.md +0 -44
- package/core/templates/steps/init-pipeline/02-interview-gaps.md +0 -55
- package/core/templates/steps/init-pipeline/03-draft-profile.md +0 -10
- package/core/templates/steps/init-pipeline/04-write-render.md +0 -145
- package/core/templates/steps/init-pipeline/05-report.md +0 -12
- package/core/workflows/audit.js +0 -240
- package/core/workflows/loop.js +0 -641
- package/core/workflows/refactor.js +0 -276
- package/core/workflows/review.js +0 -456
- package/install.ps1 +0 -103
- package/install.sh +0 -114
- package/lib/doctor.js +0 -534
- package/lib/runtime.js +0 -139
- package/lib/versions.js +0 -149
- package/lib/yaml.js +0 -129
- package/profile/PIPELINE.template.md +0 -259
- package/profile/SCHEMA.md +0 -735
- package/profile/cohorte.config.template.yaml +0 -43
- package/scripts/assert-gate-hook.mjs +0 -75
- package/scripts/kanban-move.sh +0 -313
- package/scripts/metrics/collect.mjs +0 -531
- package/scripts/metrics/prices.json +0 -42
- package/scripts/new-feature.sh.template +0 -98
- package/scripts/preflight.sh +0 -124
- package/scripts/remove-feature.sh.template +0 -56
- package/scripts/test-adapter.mjs +0 -437
- package/scripts/test-gate.mjs +0 -443
- package/scripts/test-kanban.mjs +0 -304
- package/scripts/test-lib.mjs +0 -356
- package/scripts/test-metrics.mjs +0 -156
- package/scripts/test-workflows.mjs +0 -758
- package/scripts/validate-core.mjs +0 -325
|
@@ -0,0 +1,1549 @@
|
|
|
1
|
+
import { n as sha256Hex, t as canonicalJson } from "./canonical-C8UxeEmB.mjs";
|
|
2
|
+
import { M as NotImplemented, o as err, s as ok } from "./src-Cu3TxNob.mjs";
|
|
3
|
+
import { c as AgentOutput, g as ClosedEnum, o as toStrictSchema } from "./src-s7YRvk0t.mjs";
|
|
4
|
+
import { l as DEFAULT_NETWORK_POLICY } from "./schema-C9QbUW4H.mjs";
|
|
5
|
+
import { c as toolGrantProblems, f as AGENT_GIT_DENIED_SUBCOMMANDS, h as isTrampoline, m as PROTECTED_REPO_GLOBS, s as ToolGrant } from "./src-CId6kMsY.mjs";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { Type } from "typebox";
|
|
8
|
+
import { Compile } from "typebox/compile";
|
|
9
|
+
import { readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
10
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
11
|
+
import { accessSync, constants, lstatSync, readdirSync, realpathSync } from "node:fs";
|
|
12
|
+
import picomatch from "picomatch";
|
|
13
|
+
//#region ../../packages/security/src/decide/commands/profiles.ts
|
|
14
|
+
/** True for a token that starts a run of leading options, e.g. `-C`, `--dir=x`, `-e"code"`. */
|
|
15
|
+
const isFlagToken = (token) => token.startsWith("-") && token !== "-";
|
|
16
|
+
/** True when `token` invokes `flag`, whether joined by `=` (long options) or directly attached (short options). */
|
|
17
|
+
function matchesFlag(token, flag) {
|
|
18
|
+
if (token === flag) return true;
|
|
19
|
+
if (flag.startsWith("--")) return token.startsWith(`${flag}=`);
|
|
20
|
+
return flag.length === 2 && token.length > flag.length && token.startsWith(flag);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* One shared structural parser for every built-in profile: walk leading flag tokens (denying a re-targeting one on
|
|
24
|
+
* sight), the first non-flag token is the subcommand, everything after it splits into `flags` / `positionals` by
|
|
25
|
+
* whether it starts with `-`. This is a STRUCTURAL split, not a full CLI grammar: it does not know which flags of
|
|
26
|
+
* `git commit -m <msg>` consume a value, because the callers that need that (CommandRule matching) work in terms of
|
|
27
|
+
* flag NAMES and positional VALUES declared by policy data, never by re-deriving one CLI's full option grammar here.
|
|
28
|
+
*/
|
|
29
|
+
function createProfile(spec) {
|
|
30
|
+
return {
|
|
31
|
+
program: spec.program,
|
|
32
|
+
aliases: spec.aliases,
|
|
33
|
+
parse(args) {
|
|
34
|
+
const retargeted = (token) => {
|
|
35
|
+
const denied = spec.retargeting.find((flag) => matchesFlag(token, flag));
|
|
36
|
+
if (denied === void 0) return void 0;
|
|
37
|
+
return {
|
|
38
|
+
kind: "denied",
|
|
39
|
+
code: "security/command-global-option",
|
|
40
|
+
token,
|
|
41
|
+
reason: `${spec.program}: \`${denied}\` re-targets the command onto a different directory or repository`
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
const globals = [];
|
|
45
|
+
let index = 0;
|
|
46
|
+
while (index < args.length) {
|
|
47
|
+
const token = args[index];
|
|
48
|
+
if (token === void 0 || !isFlagToken(token)) break;
|
|
49
|
+
const denial = retargeted(token);
|
|
50
|
+
if (denial !== void 0) return denial;
|
|
51
|
+
globals.push(token);
|
|
52
|
+
index += 1;
|
|
53
|
+
}
|
|
54
|
+
const subcommandToken = args[index];
|
|
55
|
+
const subcommand = subcommandToken === void 0 ? [] : [subcommandToken];
|
|
56
|
+
const rest = subcommandToken === void 0 ? [] : args.slice(index + 1);
|
|
57
|
+
const flags = [];
|
|
58
|
+
const positionals = [];
|
|
59
|
+
for (const token of rest) (isFlagToken(token) ? flags : positionals).push(token);
|
|
60
|
+
for (const token of flags) {
|
|
61
|
+
const denial = retargeted(token);
|
|
62
|
+
if (denial !== void 0) return denial;
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
kind: "parsed",
|
|
66
|
+
globals,
|
|
67
|
+
subcommand,
|
|
68
|
+
flags,
|
|
69
|
+
positionals
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const GIT_RETARGETING = [
|
|
75
|
+
"-C",
|
|
76
|
+
"-c",
|
|
77
|
+
"--config-env",
|
|
78
|
+
"--git-dir",
|
|
79
|
+
"--work-tree",
|
|
80
|
+
"--exec-path",
|
|
81
|
+
"--namespace",
|
|
82
|
+
"--super-prefix",
|
|
83
|
+
"--attr-source"
|
|
84
|
+
];
|
|
85
|
+
const PNPM_RETARGETING = [
|
|
86
|
+
"-C",
|
|
87
|
+
"--dir",
|
|
88
|
+
"--prefix"
|
|
89
|
+
];
|
|
90
|
+
const NPM_RETARGETING = ["--prefix"];
|
|
91
|
+
const NODE_RETARGETING = [
|
|
92
|
+
"-e",
|
|
93
|
+
"-p",
|
|
94
|
+
"-r",
|
|
95
|
+
"--require",
|
|
96
|
+
"--import",
|
|
97
|
+
"--loader",
|
|
98
|
+
"--experimental-loader",
|
|
99
|
+
"--eval",
|
|
100
|
+
"--env-file",
|
|
101
|
+
"--env-file-if-exists"
|
|
102
|
+
];
|
|
103
|
+
/** `yarn --cwd <dir>` runs the whole command in another directory: `git -C` under another name. */
|
|
104
|
+
const YARN_RETARGETING = ["--cwd"];
|
|
105
|
+
/** `docker -H`/`--host`/`--context` re-target the DAEMON (a remote one included); `--config` the credential store. */
|
|
106
|
+
const DOCKER_RETARGETING = [
|
|
107
|
+
"-H",
|
|
108
|
+
"--host",
|
|
109
|
+
"--context",
|
|
110
|
+
"--config"
|
|
111
|
+
];
|
|
112
|
+
const GIT_PROFILE = createProfile({
|
|
113
|
+
program: "git",
|
|
114
|
+
aliases: [],
|
|
115
|
+
retargeting: GIT_RETARGETING
|
|
116
|
+
});
|
|
117
|
+
const PNPM_PROFILE = createProfile({
|
|
118
|
+
program: "pnpm",
|
|
119
|
+
aliases: [],
|
|
120
|
+
retargeting: PNPM_RETARGETING
|
|
121
|
+
});
|
|
122
|
+
const NPM_PROFILE = createProfile({
|
|
123
|
+
program: "npm",
|
|
124
|
+
aliases: [],
|
|
125
|
+
retargeting: NPM_RETARGETING
|
|
126
|
+
});
|
|
127
|
+
const YARN_PROFILE = createProfile({
|
|
128
|
+
program: "yarn",
|
|
129
|
+
aliases: [],
|
|
130
|
+
retargeting: YARN_RETARGETING
|
|
131
|
+
});
|
|
132
|
+
const NODE_PROFILE = createProfile({
|
|
133
|
+
program: "node",
|
|
134
|
+
aliases: [],
|
|
135
|
+
retargeting: NODE_RETARGETING
|
|
136
|
+
});
|
|
137
|
+
/** `docker-compose up` normalises to `docker compose up`: EV-10. */
|
|
138
|
+
const DOCKER_PROFILE = createProfile({
|
|
139
|
+
program: "docker",
|
|
140
|
+
aliases: ["docker-compose"],
|
|
141
|
+
retargeting: DOCKER_RETARGETING
|
|
142
|
+
});
|
|
143
|
+
const BUILTIN_PROFILES = Object.freeze([
|
|
144
|
+
GIT_PROFILE,
|
|
145
|
+
PNPM_PROFILE,
|
|
146
|
+
NPM_PROFILE,
|
|
147
|
+
YARN_PROFILE,
|
|
148
|
+
NODE_PROFILE,
|
|
149
|
+
DOCKER_PROFILE
|
|
150
|
+
]);
|
|
151
|
+
/**
|
|
152
|
+
* Subcommand tokens an alias implies, prepended to argv[1..] before the matched profile parses it: `docker-compose`
|
|
153
|
+
* behaves as `docker compose`. No other built-in alias carries an implied subcommand.
|
|
154
|
+
*/
|
|
155
|
+
const ALIAS_SUBCOMMAND_PREFIX = Object.freeze({ "docker-compose": Object.freeze(["compose"]) });
|
|
156
|
+
/** `bareName` is argv[0] as typed (already checked to be a bare name with no path separator). */
|
|
157
|
+
function findProfile(bareName, profiles) {
|
|
158
|
+
for (const profile of profiles) {
|
|
159
|
+
if (profile.program === bareName) return {
|
|
160
|
+
profile,
|
|
161
|
+
prefix: []
|
|
162
|
+
};
|
|
163
|
+
if (profile.aliases.includes(bareName)) return {
|
|
164
|
+
profile,
|
|
165
|
+
prefix: ALIAS_SUBCOMMAND_PREFIX[bareName] ?? []
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
//#endregion
|
|
170
|
+
//#region ../../packages/security/src/decide/commands/resolver.ts
|
|
171
|
+
const NODE_FS = {
|
|
172
|
+
accessSync,
|
|
173
|
+
realpathSync
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* `bareName` SHOULD already be a bare name (no path separator): that is checked one layer up, as the very first
|
|
177
|
+
* thing `evaluate()` does (DESIGN 2.6.4 step 1). This resolver enforces it too, defensively: `node:path.join`
|
|
178
|
+
* normalises a segment like `./node` or `../x` away, so without this guard a relative-looking name would silently
|
|
179
|
+
* resolve through directory-list normalisation rather than being rejected outright, for any OTHER caller of this
|
|
180
|
+
* port that does not repeat the step-1 check.
|
|
181
|
+
*/
|
|
182
|
+
function createProgramResolver(options) {
|
|
183
|
+
const dirs = [...options.pathDirs];
|
|
184
|
+
const fs = options.fs ?? NODE_FS;
|
|
185
|
+
const cache = /* @__PURE__ */ new Map();
|
|
186
|
+
return { resolve(bareName) {
|
|
187
|
+
if (bareName === "" || bareName.includes("/") || bareName.includes("\\")) return void 0;
|
|
188
|
+
const cached = cache.get(bareName);
|
|
189
|
+
if (cached !== void 0 || cache.has(bareName)) return cached;
|
|
190
|
+
let found;
|
|
191
|
+
for (const dir of dirs) {
|
|
192
|
+
const candidate = join(dir, bareName);
|
|
193
|
+
try {
|
|
194
|
+
fs.accessSync(candidate, constants.X_OK);
|
|
195
|
+
found = fs.realpathSync(candidate);
|
|
196
|
+
break;
|
|
197
|
+
} catch {}
|
|
198
|
+
}
|
|
199
|
+
cache.set(bareName, found);
|
|
200
|
+
return found;
|
|
201
|
+
} };
|
|
202
|
+
}
|
|
203
|
+
//#endregion
|
|
204
|
+
//#region ../../packages/security/src/decide/commands/index.ts
|
|
205
|
+
const tokensEqual = (a, b) => a.length === b.length && a.every((value, index) => value === b[index]);
|
|
206
|
+
/** `path` inside `root`, by path SEGMENTS (never `startsWith` alone: `/w/api` must not contain `/w/api-gateway`). */
|
|
207
|
+
const isContainedIn = (path, root) => path === root || path.startsWith(`${root}/`);
|
|
208
|
+
/**
|
|
209
|
+
* "Anything not allowed is denied" (DESIGN 2.6.4) is a rule about what may be PERMITTED, so the allowlist only ever
|
|
210
|
+
* narrows an `allow` rule — where narrowing fails closed. Applied to a `deny` or an `ask` rule it narrows in the
|
|
211
|
+
* other direction: the restrictive rule stops matching as soon as a flag it did not declare is present, while a
|
|
212
|
+
* broader `allow` rule that DOES list that flag keeps matching, and `deny > ask > allow` (step 5) is defeated by one
|
|
213
|
+
* token (`pnpm --silent run build`). A restrictive rule therefore matches on program + subcommand + positionals
|
|
214
|
+
* alone. Recorded in docs/v3/requests/U1.03.md R4 item 3.
|
|
215
|
+
*/
|
|
216
|
+
function flagsMatch(flags, rule) {
|
|
217
|
+
if (flags.length === 0 || rule.decision !== "allow") return true;
|
|
218
|
+
const spec = rule.flags;
|
|
219
|
+
if (spec === void 0) return false;
|
|
220
|
+
return flags.every((flag) => !spec.deny?.includes(flag) && spec.allow.includes(flag));
|
|
221
|
+
}
|
|
222
|
+
/** Structural, not filesystem-aware: real containment/symlink checks of path-typed arguments are stage 3's job. */
|
|
223
|
+
const looksLikeWorktreeRelativePath = (value) => !value.startsWith("-") && !value.startsWith("/") && !value.startsWith("~") && !value.split(/[/\\]/).includes("..");
|
|
224
|
+
function positionalsMatch(positionals, spec) {
|
|
225
|
+
if (spec === void 0) return positionals.length === 0;
|
|
226
|
+
switch (spec.kind) {
|
|
227
|
+
case "none": return positionals.length === 0;
|
|
228
|
+
case "exact": return tokensEqual(positionals, spec.values);
|
|
229
|
+
case "enum": return positionals.length > 0 && positionals.length <= spec.max && positionals.every((value) => spec.values.includes(value));
|
|
230
|
+
case "paths-in-worktree": return positionals.length > 0 && positionals.length <= spec.max && positionals.every(looksLikeWorktreeRelativePath);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
/** A rule with no `subcommand`, no `flags` and `positionals.kind: 'exact'` matches the WHOLE rest of argv, whether
|
|
234
|
+
* or not the program has a profile: this is how `dangerousCommands` and `checksToCommandRules` rules match. */
|
|
235
|
+
function matchesExactArgv(rule, rest) {
|
|
236
|
+
if (rule.subcommand !== void 0 || rule.flags !== void 0) return false;
|
|
237
|
+
const positionals = rule.positionals;
|
|
238
|
+
return positionals !== void 0 && positionals.kind === "exact" && tokensEqual(rest, positionals.values);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Only reachable for a profiled program: `(subcommand, flags, positionals)`, per DESIGN 2.6.4 step 4.
|
|
242
|
+
*
|
|
243
|
+
* The profile's structural split is deliberately shallow — the FIRST non-flag token is `subcommand`, every later
|
|
244
|
+
* non-flag token is a positional — so a rule's `subcommand` is matched as a PREFIX of the whole non-flag token
|
|
245
|
+
* stream. That is what makes DESIGN 2.6.4's own example (`subcommand: ['run','test']`) and `['compose','up']`
|
|
246
|
+
* reachable, and it keeps `docker-compose up` ≡ `docker compose up`. The tokens the prefix consumes are removed
|
|
247
|
+
* before the positional check; an absent or empty `subcommand` is "no subcommand constraint", not "never matches".
|
|
248
|
+
*
|
|
249
|
+
* Deviation (recorded): a rule's `flags` allowlist is matched against `globals` TOO, not only against the flags
|
|
250
|
+
* that follow the subcommand. DESIGN 2.6.4 says of `flags` "anything not allowed is denied"; leaving the leading
|
|
251
|
+
* globals unchecked would let `pnpm --silent run test` satisfy a rule written for `pnpm run test`, which fails
|
|
252
|
+
* OPEN. Benign globals are still parsed as globals (EV-08/09: `git --no-pager push` still has subcommand `push`);
|
|
253
|
+
* a policy that wants to permit one lists it in `flags.allow`.
|
|
254
|
+
*/
|
|
255
|
+
function matchesStructurally(rule, parsed) {
|
|
256
|
+
const tokens = [...parsed.subcommand, ...parsed.positionals];
|
|
257
|
+
const required = rule.subcommand ?? [];
|
|
258
|
+
if (required.length > tokens.length) return false;
|
|
259
|
+
if (!required.every((token, index) => token === tokens[index])) return false;
|
|
260
|
+
return flagsMatch([...parsed.globals, ...parsed.flags], rule) && positionalsMatch(tokens.slice(required.length), rule.positionals);
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* The D9 subcommand of a `git` call, if it is one of the denied ones (ADR-0007 item 2).
|
|
264
|
+
*
|
|
265
|
+
* With a structural parse in hand the subcommand is exact, and the profile has already denied every value-taking
|
|
266
|
+
* re-targeting global. Without one — a caller passed `profiles` without git — nothing can tell a subcommand from a
|
|
267
|
+
* global option's VALUE (`git --super-prefix x commit`), so every token is held against the set: conservative in
|
|
268
|
+
* the only safe direction for an invariant that must not be switchable off.
|
|
269
|
+
*/
|
|
270
|
+
function deniedGitSubcommand(parsed, rest) {
|
|
271
|
+
const denied = AGENT_GIT_DENIED_SUBCOMMANDS;
|
|
272
|
+
if (parsed === void 0) return rest.find((token) => denied.includes(token));
|
|
273
|
+
const subcommand = parsed.subcommand[0];
|
|
274
|
+
return subcommand !== void 0 && denied.includes(subcommand) ? subcommand : void 0;
|
|
275
|
+
}
|
|
276
|
+
function branchAllows(rule, branch) {
|
|
277
|
+
return (rule.when?.branch ?? "any") === "any" || branch.kind === "branch" && !branch.protected;
|
|
278
|
+
}
|
|
279
|
+
function whenMatches(rule, context, branch) {
|
|
280
|
+
if (!branchAllows(rule, branch)) return false;
|
|
281
|
+
if (rule.when?.roles && !rule.when.roles.includes(context.role)) return false;
|
|
282
|
+
if (rule.when?.phases && !rule.when.phases.includes(context.phase)) return false;
|
|
283
|
+
return true;
|
|
284
|
+
}
|
|
285
|
+
const DECISION_RANK = {
|
|
286
|
+
deny: 0,
|
|
287
|
+
ask: 1,
|
|
288
|
+
allow: 2
|
|
289
|
+
};
|
|
290
|
+
/** deny beats ask beats allow (DESIGN 2.6.4 step 5, 2.6.2 precedence). `matches` must be non-empty. */
|
|
291
|
+
function pickWinner(matches) {
|
|
292
|
+
return matches.reduce((best, rule) => DECISION_RANK[rule.decision] < DECISION_RANK[best.decision] ? rule : best);
|
|
293
|
+
}
|
|
294
|
+
/** Every rule of `program`, structurally or exactly matching, whose `when` is satisfied. Records every rule id it
|
|
295
|
+
* looked at in `evaluatedRules`, matched or not (`PolicyVerdict.evaluatedRules`, "for `cohorte policy explain`"). */
|
|
296
|
+
function matchRules(rules, program, rest, parsed, context, branch, evaluatedRules) {
|
|
297
|
+
const matches = [];
|
|
298
|
+
for (const rule of rules) {
|
|
299
|
+
if (rule.program !== program) continue;
|
|
300
|
+
evaluatedRules.push(rule.id);
|
|
301
|
+
const structurallyOk = parsed !== void 0 && matchesStructurally(rule, parsed);
|
|
302
|
+
const exactlyOk = matchesExactArgv(rule, rest);
|
|
303
|
+
if ((structurallyOk || exactlyOk) && whenMatches(rule, context, branch)) matches.push(rule);
|
|
304
|
+
}
|
|
305
|
+
return matches;
|
|
306
|
+
}
|
|
307
|
+
function denyEval(args) {
|
|
308
|
+
const { ruleId, code, reason, overridable, securityViolation, evaluatedRules } = args;
|
|
309
|
+
return {
|
|
310
|
+
decision: "deny",
|
|
311
|
+
ruleId,
|
|
312
|
+
reason,
|
|
313
|
+
overridable,
|
|
314
|
+
securityViolation,
|
|
315
|
+
evaluatedRules,
|
|
316
|
+
...code === void 0 ? {} : { code }
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
function applyRule(rule, args) {
|
|
320
|
+
const { resolvedFile, rest, context, timeoutMs, evaluatedRules } = args;
|
|
321
|
+
const overridable = rule.origin !== "builtin";
|
|
322
|
+
if (rule.decision === "deny") return denyEval({
|
|
323
|
+
ruleId: rule.id,
|
|
324
|
+
code: "permission/command-not-allowed",
|
|
325
|
+
reason: `denied by rule ${rule.id}`,
|
|
326
|
+
overridable,
|
|
327
|
+
securityViolation: false,
|
|
328
|
+
evaluatedRules
|
|
329
|
+
});
|
|
330
|
+
if (rule.network && context.sandboxLevel === "L0-process") return denyEval({
|
|
331
|
+
ruleId: rule.id,
|
|
332
|
+
code: "permission/network-denied",
|
|
333
|
+
reason: `rule ${rule.id} needs network access, which the L0 process sandbox cannot enforce a deny for`,
|
|
334
|
+
overridable: false,
|
|
335
|
+
securityViolation: false,
|
|
336
|
+
evaluatedRules
|
|
337
|
+
});
|
|
338
|
+
const command = {
|
|
339
|
+
file: resolvedFile,
|
|
340
|
+
args: [...rest],
|
|
341
|
+
cwd: context.cwd,
|
|
342
|
+
ruleId: rule.id,
|
|
343
|
+
replay: rule.replay,
|
|
344
|
+
network: rule.network,
|
|
345
|
+
timeoutMs: timeoutMs ?? context.defaultTimeoutMs
|
|
346
|
+
};
|
|
347
|
+
return {
|
|
348
|
+
decision: rule.decision === "ask" ? "ask" : "allow",
|
|
349
|
+
ruleId: rule.id,
|
|
350
|
+
reason: rule.decision === "ask" ? `rule ${rule.id} requires confirmation` : `allowed by rule ${rule.id}`,
|
|
351
|
+
overridable: true,
|
|
352
|
+
securityViolation: false,
|
|
353
|
+
evaluatedRules,
|
|
354
|
+
command
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
function createCommandPolicy(options) {
|
|
358
|
+
const profiles = options.profiles ?? BUILTIN_PROFILES;
|
|
359
|
+
return { evaluate(request, policy, context) {
|
|
360
|
+
const evaluatedRules = [];
|
|
361
|
+
const argv = request.argv;
|
|
362
|
+
const bareName = argv[0];
|
|
363
|
+
if (bareName === void 0 || bareName === "") return denyEval({
|
|
364
|
+
ruleId: "builtin/argv-empty",
|
|
365
|
+
code: "permission/command-not-allowed",
|
|
366
|
+
reason: "argv is empty: there is no program to run",
|
|
367
|
+
overridable: false,
|
|
368
|
+
securityViolation: true,
|
|
369
|
+
evaluatedRules
|
|
370
|
+
});
|
|
371
|
+
if (bareName.includes("/") || bareName.includes("\\")) return denyEval({
|
|
372
|
+
ruleId: "builtin/bare-name",
|
|
373
|
+
code: "permission/command-not-allowed",
|
|
374
|
+
reason: `argv[0] "${bareName}" is not a bare program name (DESIGN 2.6.4 step 1)`,
|
|
375
|
+
overridable: false,
|
|
376
|
+
securityViolation: true,
|
|
377
|
+
evaluatedRules
|
|
378
|
+
});
|
|
379
|
+
evaluatedRules.push("builtin/resolve");
|
|
380
|
+
const resolvedFile = options.programs.resolve(bareName);
|
|
381
|
+
if (resolvedFile === void 0) return denyEval({
|
|
382
|
+
ruleId: "builtin/resolve",
|
|
383
|
+
code: "permission/command-not-allowed",
|
|
384
|
+
reason: `"${bareName}" does not resolve to a program on the PATH pinned at run start`,
|
|
385
|
+
overridable: true,
|
|
386
|
+
securityViolation: false,
|
|
387
|
+
evaluatedRules
|
|
388
|
+
});
|
|
389
|
+
evaluatedRules.push("builtin/cwd-containment");
|
|
390
|
+
if (!isContainedIn(context.cwd, context.worktree)) return denyEval({
|
|
391
|
+
ruleId: "builtin/cwd-containment",
|
|
392
|
+
code: "permission/command-not-allowed",
|
|
393
|
+
reason: `cwd "${context.cwd}" is outside the agent's own worktree "${context.worktree}"`,
|
|
394
|
+
overridable: false,
|
|
395
|
+
securityViolation: true,
|
|
396
|
+
evaluatedRules
|
|
397
|
+
});
|
|
398
|
+
const branch = options.branches.branchOf(context.cwd);
|
|
399
|
+
const match = findProfile(bareName, profiles);
|
|
400
|
+
const canonicalName = match?.profile.program ?? bareName;
|
|
401
|
+
const rest = argv.slice(1);
|
|
402
|
+
evaluatedRules.push("builtin/trampolines");
|
|
403
|
+
if (isTrampoline(bareName) || isTrampoline(canonicalName)) {
|
|
404
|
+
const matches = matchRules(policy.rules, canonicalName, rest, void 0, context, branch, evaluatedRules);
|
|
405
|
+
const winner = matches.length > 0 ? pickWinner(matches) : void 0;
|
|
406
|
+
if (winner !== void 0) {
|
|
407
|
+
const evaluation = applyRule(winner.decision === "deny" ? winner : {
|
|
408
|
+
...winner,
|
|
409
|
+
decision: "ask"
|
|
410
|
+
}, {
|
|
411
|
+
resolvedFile,
|
|
412
|
+
rest,
|
|
413
|
+
context,
|
|
414
|
+
timeoutMs: request.timeoutMs,
|
|
415
|
+
evaluatedRules
|
|
416
|
+
});
|
|
417
|
+
if (evaluation.decision !== "ask") return evaluation;
|
|
418
|
+
return {
|
|
419
|
+
...evaluation,
|
|
420
|
+
reason: `"${bareName}" launches another program on behalf of the agent: rule ${winner.id} matches, but every use of a trampoline is \`ask\` (ADR-0024)`
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
return denyEval({
|
|
424
|
+
ruleId: "builtin/trampolines",
|
|
425
|
+
code: "security/command-trampoline",
|
|
426
|
+
reason: `"${bareName}" would launch another program on behalf of the agent`,
|
|
427
|
+
overridable: false,
|
|
428
|
+
securityViolation: true,
|
|
429
|
+
evaluatedRules
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
let parsed;
|
|
433
|
+
if (match !== void 0) {
|
|
434
|
+
evaluatedRules.push("builtin/profile-parse");
|
|
435
|
+
const outcome = match.profile.parse([...match.prefix, ...rest]);
|
|
436
|
+
if (outcome.kind === "denied") return denyEval({
|
|
437
|
+
ruleId: "builtin/global-option",
|
|
438
|
+
code: outcome.code,
|
|
439
|
+
reason: outcome.reason,
|
|
440
|
+
overridable: false,
|
|
441
|
+
securityViolation: true,
|
|
442
|
+
evaluatedRules
|
|
443
|
+
});
|
|
444
|
+
parsed = outcome;
|
|
445
|
+
}
|
|
446
|
+
if (canonicalName === "git") {
|
|
447
|
+
const subcommand = deniedGitSubcommand(parsed, rest);
|
|
448
|
+
if (subcommand !== void 0) {
|
|
449
|
+
evaluatedRules.push("builtin/agent-git-deny");
|
|
450
|
+
return denyEval({
|
|
451
|
+
ruleId: "builtin/agent-git-deny",
|
|
452
|
+
code: "permission/command-not-allowed",
|
|
453
|
+
reason: `agents may not run \`git ${subcommand}\`: Cohorte creates every commit (ADR-0007)`,
|
|
454
|
+
overridable: false,
|
|
455
|
+
securityViolation: true,
|
|
456
|
+
evaluatedRules
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
const matches = matchRules(policy.rules, canonicalName, rest, parsed, context, branch, evaluatedRules);
|
|
461
|
+
if (matches.length === 0) return denyEval({
|
|
462
|
+
ruleId: "builtin/no-rule",
|
|
463
|
+
code: "permission/command-not-allowed",
|
|
464
|
+
reason: `no command rule matches "${[bareName, ...rest].join(" ")}"; the default is deny`,
|
|
465
|
+
overridable: true,
|
|
466
|
+
securityViolation: false,
|
|
467
|
+
evaluatedRules
|
|
468
|
+
});
|
|
469
|
+
return applyRule(pickWinner(matches), {
|
|
470
|
+
resolvedFile,
|
|
471
|
+
rest,
|
|
472
|
+
context,
|
|
473
|
+
timeoutMs: request.timeoutMs,
|
|
474
|
+
evaluatedRules
|
|
475
|
+
});
|
|
476
|
+
} };
|
|
477
|
+
}
|
|
478
|
+
const closed = { additionalProperties: false };
|
|
479
|
+
Type.Object({
|
|
480
|
+
decision: Type.Union([
|
|
481
|
+
Type.Literal("allow"),
|
|
482
|
+
Type.Literal("ask"),
|
|
483
|
+
Type.Literal("deny")
|
|
484
|
+
]),
|
|
485
|
+
ruleId: Type.String({ minLength: 1 }),
|
|
486
|
+
code: Type.Optional(Type.String({ minLength: 1 })),
|
|
487
|
+
reason: Type.String(),
|
|
488
|
+
overridable: Type.Boolean(),
|
|
489
|
+
securityViolation: Type.Boolean(),
|
|
490
|
+
evaluatedRules: Type.Array(Type.String()),
|
|
491
|
+
command: Type.Optional(Type.Object({
|
|
492
|
+
file: Type.String({ minLength: 1 }),
|
|
493
|
+
args: Type.Array(Type.String()),
|
|
494
|
+
cwd: Type.String({ minLength: 1 }),
|
|
495
|
+
ruleId: Type.String({ minLength: 1 }),
|
|
496
|
+
replay: Type.Union([Type.Literal("idempotent"), Type.Literal("at-most-once")]),
|
|
497
|
+
network: Type.Boolean(),
|
|
498
|
+
timeoutMs: Type.Integer({ minimum: 0 })
|
|
499
|
+
}, closed))
|
|
500
|
+
}, closed);
|
|
501
|
+
//#endregion
|
|
502
|
+
//#region ../../packages/security/src/decide/gate/index.ts
|
|
503
|
+
const deny = (stage, ruleId, reason, code = "permission/tool-not-granted", securityViolation = false) => ({
|
|
504
|
+
decision: "deny",
|
|
505
|
+
stage,
|
|
506
|
+
ruleId,
|
|
507
|
+
reason: `${code}: ${reason}`,
|
|
508
|
+
modelFacingReason: `${reason}. Do not retry.`,
|
|
509
|
+
overridable: !securityViolation,
|
|
510
|
+
securityViolation,
|
|
511
|
+
asks: [],
|
|
512
|
+
evaluatedRules: [ruleId],
|
|
513
|
+
normalized: null
|
|
514
|
+
});
|
|
515
|
+
const invalidPolicy = (message) => {
|
|
516
|
+
throw new Error(`configuration/policy-invalid: ${message}`);
|
|
517
|
+
};
|
|
518
|
+
const cloneFreeze = (value) => {
|
|
519
|
+
if (value && typeof value === "object") {
|
|
520
|
+
Object.freeze(value);
|
|
521
|
+
for (const child of Object.values(value)) cloneFreeze(child);
|
|
522
|
+
}
|
|
523
|
+
return value;
|
|
524
|
+
};
|
|
525
|
+
function createPolicyEngine(options) {
|
|
526
|
+
if (!options?.tools || !options.globs || !options.commands) invalidPolicy("policy engine ports are incomplete");
|
|
527
|
+
return { evaluate(call, grant, policy, ports) {
|
|
528
|
+
try {
|
|
529
|
+
const schema = options.tools.schemaOf(call.tool);
|
|
530
|
+
if (schema === void 0) return deny("schema", "builtin/unknown-tool", `unknown tool "${call.tool}"`, "validation/tool-input", true);
|
|
531
|
+
if (!Compile(schema).Check(call.input)) return deny("schema", "builtin/tool-schema", `input for "${call.tool}" does not match its strict schema`, "validation/tool-input");
|
|
532
|
+
const evaluatedRules = ["builtin/tool-schema"];
|
|
533
|
+
if (!grant.tools.includes(call.tool)) return {
|
|
534
|
+
...deny("capability", "grant/tool-not-granted", `tool "${call.tool}" is not granted to this agent`),
|
|
535
|
+
evaluatedRules
|
|
536
|
+
};
|
|
537
|
+
const normalizedPaths = [];
|
|
538
|
+
const base = grant.roots.workspace ?? grant.roots.readOnly[0];
|
|
539
|
+
if (base === void 0) return deny("path", "grant/no-root", "the agent has no workspace or read-only root", "permission/path-outside-grant");
|
|
540
|
+
for (const arg of options.tools.pathArgsOf(call.tool, call.input)) {
|
|
541
|
+
const resolved = ports.paths.resolve(arg.value, base, arg.intent);
|
|
542
|
+
if (!resolved.ok) return deny("path", `path/${resolved.error.code}`, `path argument "${arg.arg}" was refused: ${resolved.error.detail}`, resolved.error.security ? `security/${resolved.error.code}` : "permission/path-outside-grant", resolved.error.security);
|
|
543
|
+
if (options.globs.isDenied(resolved.value.relative, grant, arg.intent === "read" || arg.intent === "list" ? "read" : "write")) return deny("path", "grant/deny-glob", `path argument "${arg.arg}" is denied by the grant`);
|
|
544
|
+
normalizedPaths.push({
|
|
545
|
+
arg: arg.arg,
|
|
546
|
+
resolved: resolved.value,
|
|
547
|
+
intent: arg.intent
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
let command;
|
|
551
|
+
if (call.tool === "network_request") return deny("network", "builtin/network-denied", "network requests are disabled in V3.0", "permission/network-denied");
|
|
552
|
+
if (call.tool === "run_command") {
|
|
553
|
+
const input = call.input;
|
|
554
|
+
const argv = Array.isArray(input.argv) && input.argv.every((v) => typeof v === "string") ? input.argv : [];
|
|
555
|
+
const cwd = typeof input.cwd === "string" ? input.cwd : "";
|
|
556
|
+
const cwdResult = ports.paths.resolve(cwd, base, "exec-cwd");
|
|
557
|
+
if (!cwdResult.ok) return deny("command", "command/cwd", "command cwd is outside the grant", "permission/command-not-allowed", true);
|
|
558
|
+
const evaluation = options.commands.evaluate({
|
|
559
|
+
argv,
|
|
560
|
+
cwd
|
|
561
|
+
}, grant.commands, {
|
|
562
|
+
worktree: grant.roots.workspace ?? cwdResult.value.root,
|
|
563
|
+
cwd: cwdResult.value.canonical,
|
|
564
|
+
role: grant.role,
|
|
565
|
+
phase: call.phase,
|
|
566
|
+
sandboxLevel: policy.sandboxLevel,
|
|
567
|
+
defaultTimeoutMs: grant.limits.perTool.run_command?.timeoutMs ?? 6e4
|
|
568
|
+
});
|
|
569
|
+
evaluatedRules.push(...evaluation.evaluatedRules);
|
|
570
|
+
if (evaluation.decision === "deny" || evaluation.command === void 0) return deny("command", evaluation.ruleId, evaluation.reason, evaluation.code, evaluation.securityViolation);
|
|
571
|
+
command = evaluation.command;
|
|
572
|
+
}
|
|
573
|
+
const limit = grant.limits.perTool[call.tool];
|
|
574
|
+
const levels = [
|
|
575
|
+
["run", call.runId],
|
|
576
|
+
["phase", call.phase],
|
|
577
|
+
["agent", call.agentId],
|
|
578
|
+
["tool", call.tool]
|
|
579
|
+
];
|
|
580
|
+
for (const [level, id] of levels) {
|
|
581
|
+
const remaining = ports.budgets.remaining(level, id);
|
|
582
|
+
if ((remaining.toolCalls ?? 1) <= 0 || (remaining.wallClockMs ?? 1) <= 0) return deny("budget", `budget/${level}`, `${level} budget is exhausted`, "budget/exhausted");
|
|
583
|
+
}
|
|
584
|
+
if (ports.budgets.callsInLastMinute(call.agentId, call.tool) >= grant.limits.maxCallsPerMinute) return deny("budget", "budget/rate-limit", "tool rate limit is exhausted", "budget/rate-limit");
|
|
585
|
+
const timeoutMs = Math.max(1, Math.min(limit?.timeoutMs ?? 6e4, command?.timeoutMs ?? Number.MAX_SAFE_INTEGER, ports.budgets.remaining("agent", call.agentId).wallClockMs ?? Number.MAX_SAFE_INTEGER));
|
|
586
|
+
return {
|
|
587
|
+
decision: "allow",
|
|
588
|
+
stage: "budget",
|
|
589
|
+
ruleId: command?.ruleId ?? "grant/tool",
|
|
590
|
+
reason: "allowed by the policy",
|
|
591
|
+
modelFacingReason: "",
|
|
592
|
+
overridable: true,
|
|
593
|
+
securityViolation: false,
|
|
594
|
+
asks: [],
|
|
595
|
+
evaluatedRules,
|
|
596
|
+
normalized: {
|
|
597
|
+
tool: call.tool,
|
|
598
|
+
paths: normalizedPaths,
|
|
599
|
+
...command ? { command: {
|
|
600
|
+
...command,
|
|
601
|
+
timeoutMs
|
|
602
|
+
} } : {},
|
|
603
|
+
input: call.input,
|
|
604
|
+
grantKeyMaterial: {
|
|
605
|
+
tool: call.tool,
|
|
606
|
+
input: call.input
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
} catch (error) {
|
|
611
|
+
return deny("schema", "security/gate-internal-error", error instanceof Error ? error.message : "gate failed closed", "security/gate-internal-error", true);
|
|
612
|
+
}
|
|
613
|
+
} };
|
|
614
|
+
}
|
|
615
|
+
/** Missing, unparseable or empty policy => `configuration/policy-invalid`: no tool ever executes. */
|
|
616
|
+
function buildPolicySnapshot(config, ownership, options) {
|
|
617
|
+
if (!config || config.policy === void 0 || !ownership || options?.sandboxLevel === void 0) invalidPolicy("missing policy or ownership");
|
|
618
|
+
const commands = [
|
|
619
|
+
...config.policy.commands.deny,
|
|
620
|
+
...config.policy.commands.ask,
|
|
621
|
+
...config.policy.commands.allow,
|
|
622
|
+
...config.policy.dangerousCommands
|
|
623
|
+
];
|
|
624
|
+
if (commands.some((rule) => rule.origin !== "project-config" && rule.origin !== "project-checks")) invalidPolicy("policy command origin is invalid");
|
|
625
|
+
const snapshot = {
|
|
626
|
+
commands: Object.freeze({
|
|
627
|
+
default: "deny",
|
|
628
|
+
rules: commands.map((rule) => ({ ...rule }))
|
|
629
|
+
}),
|
|
630
|
+
symlinks: { ...config.policy.symlinks },
|
|
631
|
+
network: { ...DEFAULT_NETWORK_POLICY },
|
|
632
|
+
protectedBranches: [...config.project.protectedBranches],
|
|
633
|
+
ownership: cloneFreeze(structuredClone(ownership)),
|
|
634
|
+
sandboxLevel: options.sandboxLevel
|
|
635
|
+
};
|
|
636
|
+
return Object.freeze({
|
|
637
|
+
...snapshot,
|
|
638
|
+
digest: sha256Hex(canonicalJson(snapshot))
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
/** `cohorte policy explain -- <argv…>`: stages 1-5, offline, nothing executes. */
|
|
642
|
+
function explainPolicy(engine, call, grant, policy, ports) {
|
|
643
|
+
if (!engine || !call || !grant || !policy || !ports) invalidPolicy("policy explain requires a complete call, grant, snapshot and ports");
|
|
644
|
+
return engine.evaluate(call, grant, policy, ports);
|
|
645
|
+
}
|
|
646
|
+
//#endregion
|
|
647
|
+
//#region ../../packages/security/src/decide/paths/glob.ts
|
|
648
|
+
/** Locked once, here: deny sets and allow sets alike are matched with the SAME options. */
|
|
649
|
+
const PICOMATCH_OPTIONS = {
|
|
650
|
+
dot: true,
|
|
651
|
+
nocase: false
|
|
652
|
+
};
|
|
653
|
+
const NEVER = () => false;
|
|
654
|
+
function normalizePattern(pattern) {
|
|
655
|
+
return pattern.includes("/") ? pattern : `**/${pattern}`;
|
|
656
|
+
}
|
|
657
|
+
const PATTERN_CACHE_LIMIT = 512;
|
|
658
|
+
const patternCache = /* @__PURE__ */ new Map();
|
|
659
|
+
const globSetCache = /* @__PURE__ */ new WeakMap();
|
|
660
|
+
function cacheKey(patterns) {
|
|
661
|
+
return patterns.join("\0");
|
|
662
|
+
}
|
|
663
|
+
function memoize(key, compile) {
|
|
664
|
+
const cached = patternCache.get(key);
|
|
665
|
+
if (cached !== void 0) return cached;
|
|
666
|
+
const compiled = compile();
|
|
667
|
+
if (patternCache.size >= PATTERN_CACHE_LIMIT) patternCache.clear();
|
|
668
|
+
patternCache.set(key, compiled);
|
|
669
|
+
return compiled;
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* Compiled once per pattern list and memoized, so every consumer of this module shares the exact same semantics
|
|
673
|
+
* at the same cost. An empty pattern list matches nothing (picomatch(`[]`) would otherwise match everything).
|
|
674
|
+
*/
|
|
675
|
+
function compilePatterns(patterns) {
|
|
676
|
+
if (patterns.length === 0) return NEVER;
|
|
677
|
+
return memoize(`p${cacheKey(patterns)}`, () => picomatch(patterns.map(normalizePattern), PICOMATCH_OPTIONS));
|
|
678
|
+
}
|
|
679
|
+
function compileGlobSet(set) {
|
|
680
|
+
const cached = globSetCache.get(set);
|
|
681
|
+
if (cached !== void 0) return cached;
|
|
682
|
+
const compiled = memoize(`s${cacheKey(set.include)}${cacheKey(set.exclude)}`, () => {
|
|
683
|
+
const included = compilePatterns(set.include);
|
|
684
|
+
if (set.exclude.length === 0) return included;
|
|
685
|
+
const excluded = compilePatterns(set.exclude);
|
|
686
|
+
return (relativePosixPath) => included(relativePosixPath) && !excluded(relativePosixPath);
|
|
687
|
+
});
|
|
688
|
+
globSetCache.set(set, compiled);
|
|
689
|
+
return compiled;
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Neither dialect can express a RE-INCLUDE, so `toExcludeArgs` renders the exclusions alone:
|
|
693
|
+
*
|
|
694
|
+
* - `rg --glob` reads its list in order, later entries overriding earlier matches — but a POSITIVE glob turns
|
|
695
|
+
* ripgrep's override set into a whitelist (the `ignore` crate ignores every file that matches no positive
|
|
696
|
+
* glob), which would silently empty the listing. Unprovable on this machine anyway (PLAN F-1: no `rg`).
|
|
697
|
+
* - git pathspec is NOT order-dependent: `:(exclude,…)` magic is subtractive and applied AFTER the union of
|
|
698
|
+
* every positive pathspec. A rendered `:(glob)<re-include>` therefore cannot bring back what the exclusion
|
|
699
|
+
* removed, and when the caller passes no positive pathspec of its own it narrows the enumeration to the
|
|
700
|
+
* re-include, which the exclusion then empties: `git ls-files`/`git grep` return NOTHING, exit 0 (measured).
|
|
701
|
+
*
|
|
702
|
+
* The consequence is deliberate and fail-closed (I2): for a deny set with a non-empty `exclude` the external
|
|
703
|
+
* enumerator drops MORE than the matcher denies — never less. `matches()` / `isDenied` stay the authority, and
|
|
704
|
+
* a caller that needs the re-included paths in its output must enumerate without the args and post-filter.
|
|
705
|
+
*/
|
|
706
|
+
function toExcludePatterns(set) {
|
|
707
|
+
return set.include.map(normalizePattern);
|
|
708
|
+
}
|
|
709
|
+
function createGlobMatcher() {
|
|
710
|
+
function deniedBySet(relativePosixPath, grant, intent) {
|
|
711
|
+
return compileGlobSet(intent === "read" ? grant.denyRead : grant.denyWrite)(relativePosixPath);
|
|
712
|
+
}
|
|
713
|
+
return {
|
|
714
|
+
matches(relativePosixPath, set) {
|
|
715
|
+
return compileGlobSet(set)(relativePosixPath);
|
|
716
|
+
},
|
|
717
|
+
/**
|
|
718
|
+
* "Not permitted for this intent" — the whole of step 7 in one call, which is why the contract takes an
|
|
719
|
+
* `AgentGrant` and not a `GlobSet`: the deny set wins over the allow set, and a path that no allow pattern
|
|
720
|
+
* matches is denied too (fail closed, I2). That second half is what keeps the I11 ownership boundary in tool
|
|
721
|
+
* OUTPUT filtering (`search`, `list_files`, `git_diff`), where the gate's own allow stage never runs.
|
|
722
|
+
*
|
|
723
|
+
* The empty relative path is the WORKSPACE ROOT itself — what `resolve('.', root, 'list')` returns — and no
|
|
724
|
+
* picomatch pattern matches the empty string, `**` and `**/*` included. The root is not "outside the
|
|
725
|
+
* surface": it IS the surface, so an allow set that has any pattern at all covers it, and only a deny
|
|
726
|
+
* pattern (or a grant that permits nothing for this intent) can forbid it.
|
|
727
|
+
*/
|
|
728
|
+
isDenied(relativePosixPath, grant, intent) {
|
|
729
|
+
if (deniedBySet(relativePosixPath, grant, intent)) return true;
|
|
730
|
+
const allowSet = intent === "read" ? grant.read : grant.write;
|
|
731
|
+
if (relativePosixPath === "") return allowSet.include.length === 0;
|
|
732
|
+
return !compileGlobSet(allowSet)(relativePosixPath);
|
|
733
|
+
},
|
|
734
|
+
toExcludeArgs(set, dialect) {
|
|
735
|
+
const patterns = toExcludePatterns(set);
|
|
736
|
+
return dialect === "rg-glob" ? patterns.map((pattern) => `!${pattern}`) : patterns.map((pattern) => `:(exclude,glob)${pattern}`);
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
//#endregion
|
|
741
|
+
//#region ../../packages/security/src/decide/paths/resolve.ts
|
|
742
|
+
const MAX_INPUT_LENGTH = 4096;
|
|
743
|
+
const CONTROL_OR_NUL = /[\x00-\x1f\x7f]/;
|
|
744
|
+
const ENV_SYNTAX = /\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*|%[A-Za-z_][A-Za-z0-9_]*%/;
|
|
745
|
+
const HOME_SHORTHAND_SEGMENT = /^~([A-Za-z0-9_][A-Za-z0-9_-]*)?$/;
|
|
746
|
+
const WINDOWS_DRIVE = /^[A-Za-z]:[\\/]/;
|
|
747
|
+
const UNC_PREFIX = /^\\\\/;
|
|
748
|
+
const WRITE_LIKE = /* @__PURE__ */ new Set(["write", "create"]);
|
|
749
|
+
function describeError(error) {
|
|
750
|
+
return error instanceof Error ? error.message : String(error);
|
|
751
|
+
}
|
|
752
|
+
function violation(code, security, detail) {
|
|
753
|
+
return err({
|
|
754
|
+
code,
|
|
755
|
+
security,
|
|
756
|
+
detail
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
function segmentsOf(absolutePosixPath) {
|
|
760
|
+
return absolutePosixPath.split("/").filter((part) => part.length > 0);
|
|
761
|
+
}
|
|
762
|
+
/** Containment by path SEGMENTS, never `startsWith`: `apps/api` must not contain `apps/api-gateway`. */
|
|
763
|
+
function segmentsContain(longer, shorter) {
|
|
764
|
+
if (longer.length < shorter.length) return false;
|
|
765
|
+
for (let index = 0; index < shorter.length; index += 1) if (longer[index] !== shorter[index]) return false;
|
|
766
|
+
return true;
|
|
767
|
+
}
|
|
768
|
+
function findRoot(absolutePosixPath, roots) {
|
|
769
|
+
const target = segmentsOf(absolutePosixPath);
|
|
770
|
+
return roots.find((root) => segmentsContain(target, segmentsOf(root)));
|
|
771
|
+
}
|
|
772
|
+
function isWithinAny(absolutePosixPath, candidates) {
|
|
773
|
+
const target = segmentsOf(absolutePosixPath);
|
|
774
|
+
return candidates.some((candidate) => segmentsContain(target, segmentsOf(candidate)));
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* POSIX-relative form of `absolutePosixPath` to `root`, which always CONTAINS it: the caller picks `root` with
|
|
778
|
+
* `findRoot` on the CANONICAL path. It can therefore never climb with `..` — and it must not, because the deny
|
|
779
|
+
* globs and the protected-repository patterns are matched against this string, and a `..` segment matches no
|
|
780
|
+
* double-star pattern (a relative form with `..` silently disabled both layers).
|
|
781
|
+
*/
|
|
782
|
+
function relativeTo(root, absolutePosixPath) {
|
|
783
|
+
return segmentsOf(absolutePosixPath).slice(segmentsOf(root).length).join("/");
|
|
784
|
+
}
|
|
785
|
+
function isErrnoException(error) {
|
|
786
|
+
return error instanceof Error && "code" in error;
|
|
787
|
+
}
|
|
788
|
+
/** `null` for "does not exist" (ENOENT — or ENOTDIR, an ancestor turned out not to be a directory). */
|
|
789
|
+
function lstatOrNull(absolutePath) {
|
|
790
|
+
try {
|
|
791
|
+
return lstatSync(absolutePath);
|
|
792
|
+
} catch (error) {
|
|
793
|
+
if (isErrnoException(error) && (error.code === "ENOENT" || error.code === "ENOTDIR")) return null;
|
|
794
|
+
throw error;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
function isSpecialFile(stat) {
|
|
798
|
+
return stat.isFIFO() || stat.isSocket() || stat.isBlockDevice() || stat.isCharacterDevice();
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* The one point a blind create could alias an existing entry that `realpath` cannot fix for us, because the
|
|
802
|
+
* requested name does not exist yet: a case-insensitive volume already holds a differently-cased sibling.
|
|
803
|
+
*/
|
|
804
|
+
function caseInsensitiveSiblingExists(dir, requestedName) {
|
|
805
|
+
let entries;
|
|
806
|
+
try {
|
|
807
|
+
entries = readdirSync(dir);
|
|
808
|
+
} catch {
|
|
809
|
+
return false;
|
|
810
|
+
}
|
|
811
|
+
const lower = requestedName.toLowerCase();
|
|
812
|
+
return entries.some((entry) => entry !== requestedName && entry.toLowerCase() === lower);
|
|
813
|
+
}
|
|
814
|
+
function createPathResolver(options) {
|
|
815
|
+
const roots = options.roots.map((root) => root);
|
|
816
|
+
const protectedRoots = options.protectedRoots.map((root) => root);
|
|
817
|
+
const protectedRepoPath = compilePatterns(PROTECTED_REPO_GLOBS);
|
|
818
|
+
const { mode: symlinkMode, hardlinksOnWrite } = options.symlinks;
|
|
819
|
+
/** DESIGN 2.6.3 steps 3-4: one component at a time, from `root` to the leaf. */
|
|
820
|
+
function walk(root, target, intent) {
|
|
821
|
+
const remaining = segmentsOf(target).slice(segmentsOf(root).length);
|
|
822
|
+
let current = root;
|
|
823
|
+
let viaSymlink = false;
|
|
824
|
+
for (let index = 0; index < remaining.length; index += 1) {
|
|
825
|
+
const name = remaining[index];
|
|
826
|
+
if (name === void 0) break;
|
|
827
|
+
const candidate = `${current}/${name}`;
|
|
828
|
+
const isFinal = index === remaining.length - 1;
|
|
829
|
+
const stat = lstatOrNull(candidate);
|
|
830
|
+
if (stat === null) {
|
|
831
|
+
const canonicalDir = realpathSync.native(current);
|
|
832
|
+
if (WRITE_LIKE.has(intent) && caseInsensitiveSiblingExists(canonicalDir, name)) return violation("case-collision", true, `${candidate} collides, case-insensitively, with an existing entry of ${canonicalDir}`);
|
|
833
|
+
return ok({
|
|
834
|
+
canonicalDir,
|
|
835
|
+
tailSegments: remaining.slice(index),
|
|
836
|
+
exists: false,
|
|
837
|
+
viaSymlink
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
if (stat.isSymbolicLink()) {
|
|
841
|
+
if (isFinal && WRITE_LIKE.has(intent)) return violation("symlink-final-write", true, `${candidate} is a symlink; a write never follows the final path component`);
|
|
842
|
+
if (symlinkMode === "deny-all") return violation("symlink-denied", true, `${candidate} is a symlink; policy is deny-all`);
|
|
843
|
+
let linkTarget;
|
|
844
|
+
try {
|
|
845
|
+
linkTarget = realpathSync.native(candidate);
|
|
846
|
+
} catch (error) {
|
|
847
|
+
return violation("symlink-escape", true, `${candidate} could not be resolved: ${describeError(error)}`);
|
|
848
|
+
}
|
|
849
|
+
if (symlinkMode === "deny-outgoing" && findRoot(linkTarget, roots) === void 0) return violation("symlink-escape", true, `${candidate} -> ${linkTarget} leaves every root`);
|
|
850
|
+
current = linkTarget;
|
|
851
|
+
viaSymlink = true;
|
|
852
|
+
continue;
|
|
853
|
+
}
|
|
854
|
+
if (isSpecialFile(stat) && !isFinal) return violation("special-file", true, `${candidate} is a FIFO, socket or device: cannot descend into it`);
|
|
855
|
+
current = candidate;
|
|
856
|
+
}
|
|
857
|
+
return ok({
|
|
858
|
+
canonicalDir: realpathSync.native(current),
|
|
859
|
+
tailSegments: [],
|
|
860
|
+
exists: true,
|
|
861
|
+
viaSymlink
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
function resolveOnce(input, base, intent) {
|
|
865
|
+
if (CONTROL_OR_NUL.test(input)) return violation("nul-byte", true, "control character or NUL byte in path");
|
|
866
|
+
if (Buffer.byteLength(input, "utf8") > MAX_INPUT_LENGTH) return violation("too-long", true, `path exceeds ${MAX_INPUT_LENGTH} bytes`);
|
|
867
|
+
if (input.split("/").some((segment) => HOME_SHORTHAND_SEGMENT.test(segment))) return violation("outside-roots", true, "home-directory shorthand (~) is never expanded");
|
|
868
|
+
if (ENV_SYNTAX.test(input)) return violation("outside-roots", true, "environment-variable syntax is never expanded");
|
|
869
|
+
if (WINDOWS_DRIVE.test(input) || UNC_PREFIX.test(input)) return violation("outside-roots", true, "Windows drive/UNC path forms are not supported");
|
|
870
|
+
const target = resolve(base, input.normalize("NFC"));
|
|
871
|
+
const lexicalRoot = findRoot(target, roots);
|
|
872
|
+
if (lexicalRoot === void 0) return violation("outside-roots", true, `${target} is outside every root`);
|
|
873
|
+
const walked = walk(lexicalRoot, target, intent);
|
|
874
|
+
if (!walked.ok) return walked;
|
|
875
|
+
const { canonicalDir, tailSegments, exists, viaSymlink } = walked.value;
|
|
876
|
+
const canonical = tailSegments.length === 0 ? canonicalDir : `${canonicalDir}/${tailSegments.join("/")}`;
|
|
877
|
+
const root = findRoot(canonical, roots);
|
|
878
|
+
if (root === void 0) return violation("outside-roots", true, `${canonical} resolves outside every root`);
|
|
879
|
+
if (isWithinAny(canonical, protectedRoots)) return violation("protected-root", true, `${canonical} is a protected root`);
|
|
880
|
+
const relative = relativeTo(root, canonical);
|
|
881
|
+
if (protectedRepoPath(relative)) return violation("protected-root", true, `${relative} is a protected repository path`);
|
|
882
|
+
let identity;
|
|
883
|
+
if (exists) {
|
|
884
|
+
const stat = lstatSync(canonical);
|
|
885
|
+
if (isSpecialFile(stat)) return violation("special-file", true, `${canonical} is a FIFO, socket or device`);
|
|
886
|
+
if (WRITE_LIKE.has(intent) && stat.isFile() && stat.nlink > 1 && hardlinksOnWrite === "deny") return violation("hardlink-multiply-linked", true, `${canonical} has ${stat.nlink} hard links`);
|
|
887
|
+
identity = {
|
|
888
|
+
dev: stat.dev,
|
|
889
|
+
ino: stat.ino,
|
|
890
|
+
nlink: stat.nlink
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
return ok({
|
|
894
|
+
canonical,
|
|
895
|
+
relative,
|
|
896
|
+
root,
|
|
897
|
+
exists,
|
|
898
|
+
viaSymlink,
|
|
899
|
+
...identity ? { identity } : {}
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
return { resolve(input, base, intent) {
|
|
903
|
+
try {
|
|
904
|
+
return resolveOnce(input, base, intent);
|
|
905
|
+
} catch (error) {
|
|
906
|
+
return violation("outside-roots", true, `unexpected error resolving ${JSON.stringify(input)}: ${describeError(error)}`);
|
|
907
|
+
}
|
|
908
|
+
} };
|
|
909
|
+
}
|
|
910
|
+
//#endregion
|
|
911
|
+
//#region ../../packages/tools/src/catalogue/schemas.ts
|
|
912
|
+
const ReadFileInput = Type.Object({
|
|
913
|
+
path: Type.String(),
|
|
914
|
+
offset: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
915
|
+
limit: Type.Optional(Type.Integer({ minimum: 1 }))
|
|
916
|
+
});
|
|
917
|
+
const ListFilesInput = Type.Object({
|
|
918
|
+
path: Type.Optional(Type.String()),
|
|
919
|
+
glob: Type.Optional(Type.String()),
|
|
920
|
+
maxEntries: Type.Optional(Type.Integer({ minimum: 1 }))
|
|
921
|
+
});
|
|
922
|
+
const SearchInput = Type.Object({
|
|
923
|
+
pattern: Type.String(),
|
|
924
|
+
path: Type.Optional(Type.String()),
|
|
925
|
+
glob: Type.Optional(Type.String()),
|
|
926
|
+
caseInsensitive: Type.Optional(Type.Boolean()),
|
|
927
|
+
maxMatches: Type.Optional(Type.Integer({ minimum: 1 }))
|
|
928
|
+
});
|
|
929
|
+
const WriteFileInput = Type.Object({
|
|
930
|
+
path: Type.String(),
|
|
931
|
+
content: Type.String()
|
|
932
|
+
});
|
|
933
|
+
const PatchFileInput = Type.Object({
|
|
934
|
+
path: Type.String(),
|
|
935
|
+
edits: Type.Array(Type.Object({
|
|
936
|
+
oldText: Type.String(),
|
|
937
|
+
newText: Type.String()
|
|
938
|
+
}), { minItems: 1 })
|
|
939
|
+
});
|
|
940
|
+
const RunCommandInput = Type.Object({
|
|
941
|
+
argv: Type.Array(Type.String(), { minItems: 1 }),
|
|
942
|
+
cwd: Type.Optional(Type.String()),
|
|
943
|
+
timeoutMs: Type.Optional(Type.Integer({ minimum: 1 }))
|
|
944
|
+
});
|
|
945
|
+
const GitDiffInput = Type.Object({
|
|
946
|
+
base: Type.Optional(ClosedEnum([
|
|
947
|
+
"run-base",
|
|
948
|
+
"integration",
|
|
949
|
+
"checkpoint"
|
|
950
|
+
])),
|
|
951
|
+
paths: Type.Optional(Type.Array(Type.String())),
|
|
952
|
+
stat: Type.Optional(Type.Boolean())
|
|
953
|
+
});
|
|
954
|
+
const ApprovalRequestInput = Type.Object({
|
|
955
|
+
question: Type.String(),
|
|
956
|
+
options: Type.Optional(Type.Array(Type.String()))
|
|
957
|
+
});
|
|
958
|
+
const GitCommitInput = Type.Object({ message: Type.String() });
|
|
959
|
+
const NetworkRequestInput = Type.Object({
|
|
960
|
+
url: Type.String(),
|
|
961
|
+
method: Type.Optional(Type.String())
|
|
962
|
+
});
|
|
963
|
+
const SecretReadInput = Type.Object({ id: Type.String() });
|
|
964
|
+
const noPaths = () => [];
|
|
965
|
+
const stringField = (input, key) => {
|
|
966
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) return void 0;
|
|
967
|
+
const value = input[key];
|
|
968
|
+
return typeof value === "string" ? value : void 0;
|
|
969
|
+
};
|
|
970
|
+
const arrayField = (input, key) => {
|
|
971
|
+
if (typeof input !== "object" || input === null || Array.isArray(input)) return void 0;
|
|
972
|
+
const value = input[key];
|
|
973
|
+
return Array.isArray(value) ? value : void 0;
|
|
974
|
+
};
|
|
975
|
+
/** DESIGN 2.7, in table order. `submit_result.inputSchema` is `AgentOutput` itself — closed by the same
|
|
976
|
+
* `toStrictSchema` every other row goes through in `./index.ts`, so the two are deep-equal by construction. */
|
|
977
|
+
const CATALOGUE_ROWS = [
|
|
978
|
+
{
|
|
979
|
+
name: "read_file",
|
|
980
|
+
description: "Read a file as sealed text with line numbers. offset/limit page through it; binary files error; capped at 256 KiB per call.",
|
|
981
|
+
inputSchema: ReadFileInput,
|
|
982
|
+
effect: "read",
|
|
983
|
+
terminal: false,
|
|
984
|
+
pathArgsOf: (input) => {
|
|
985
|
+
const path = stringField(input, "path");
|
|
986
|
+
return path === void 0 ? [] : [{
|
|
987
|
+
arg: "path",
|
|
988
|
+
value: path,
|
|
989
|
+
intent: "read"
|
|
990
|
+
}];
|
|
991
|
+
}
|
|
992
|
+
},
|
|
993
|
+
{
|
|
994
|
+
name: "list_files",
|
|
995
|
+
description: "List files under a path (default the workspace root), filtered by glob. Deny sets are filtered out; outgoing symlinks are never followed.",
|
|
996
|
+
inputSchema: ListFilesInput,
|
|
997
|
+
effect: "read",
|
|
998
|
+
terminal: false,
|
|
999
|
+
pathArgsOf: (input) => {
|
|
1000
|
+
const path = stringField(input, "path");
|
|
1001
|
+
return path === void 0 ? [] : [{
|
|
1002
|
+
arg: "path",
|
|
1003
|
+
value: path,
|
|
1004
|
+
intent: "list"
|
|
1005
|
+
}];
|
|
1006
|
+
}
|
|
1007
|
+
},
|
|
1008
|
+
{
|
|
1009
|
+
name: "search",
|
|
1010
|
+
description: "Search file contents for a pattern (a literal argv element, never a shell string). Every hit is filtered against the deny sets and the grant before it is returned.",
|
|
1011
|
+
inputSchema: SearchInput,
|
|
1012
|
+
effect: "read",
|
|
1013
|
+
terminal: false,
|
|
1014
|
+
pathArgsOf: (input) => {
|
|
1015
|
+
const path = stringField(input, "path");
|
|
1016
|
+
return path === void 0 ? [] : [{
|
|
1017
|
+
arg: "path",
|
|
1018
|
+
value: path,
|
|
1019
|
+
intent: "list"
|
|
1020
|
+
}];
|
|
1021
|
+
}
|
|
1022
|
+
},
|
|
1023
|
+
{
|
|
1024
|
+
name: "write_file",
|
|
1025
|
+
description: "Write the complete content of a file, creating it if it does not exist. The whole file is replaced.",
|
|
1026
|
+
inputSchema: WriteFileInput,
|
|
1027
|
+
effect: "write",
|
|
1028
|
+
terminal: false,
|
|
1029
|
+
pathArgsOf: (input) => {
|
|
1030
|
+
const path = stringField(input, "path");
|
|
1031
|
+
return path === void 0 ? [] : [{
|
|
1032
|
+
arg: "path",
|
|
1033
|
+
value: path,
|
|
1034
|
+
intent: "write"
|
|
1035
|
+
}];
|
|
1036
|
+
}
|
|
1037
|
+
},
|
|
1038
|
+
{
|
|
1039
|
+
name: "patch_file",
|
|
1040
|
+
description: "Apply exact-match text edits to a file: each oldText must occur exactly once in the current content, or the call fails without writing anything.",
|
|
1041
|
+
inputSchema: PatchFileInput,
|
|
1042
|
+
effect: "write",
|
|
1043
|
+
terminal: false,
|
|
1044
|
+
pathArgsOf: (input) => {
|
|
1045
|
+
const path = stringField(input, "path");
|
|
1046
|
+
return path === void 0 ? [] : [{
|
|
1047
|
+
arg: "path",
|
|
1048
|
+
value: path,
|
|
1049
|
+
intent: "write"
|
|
1050
|
+
}];
|
|
1051
|
+
}
|
|
1052
|
+
},
|
|
1053
|
+
{
|
|
1054
|
+
name: "run_command",
|
|
1055
|
+
description: "Run one allow-listed command (argv only, never a shell line) and return its output.",
|
|
1056
|
+
inputSchema: RunCommandInput,
|
|
1057
|
+
effect: "execute",
|
|
1058
|
+
terminal: false,
|
|
1059
|
+
pathArgsOf: (input) => {
|
|
1060
|
+
const cwd = stringField(input, "cwd");
|
|
1061
|
+
return cwd === void 0 ? [] : [{
|
|
1062
|
+
arg: "cwd",
|
|
1063
|
+
value: cwd,
|
|
1064
|
+
intent: "exec-cwd"
|
|
1065
|
+
}];
|
|
1066
|
+
}
|
|
1067
|
+
},
|
|
1068
|
+
{
|
|
1069
|
+
name: "git_diff",
|
|
1070
|
+
description: "Show a diff of the worktree against 'run-base' (the run's pinned base, the default on a read-only review ref), 'integration' (the default in a slot) or 'checkpoint'.",
|
|
1071
|
+
inputSchema: GitDiffInput,
|
|
1072
|
+
effect: "read",
|
|
1073
|
+
terminal: false,
|
|
1074
|
+
pathArgsOf: (input) => {
|
|
1075
|
+
const paths = arrayField(input, "paths");
|
|
1076
|
+
if (!paths) return [];
|
|
1077
|
+
const out = [];
|
|
1078
|
+
paths.forEach((value, index) => {
|
|
1079
|
+
if (typeof value === "string") out.push({
|
|
1080
|
+
arg: `paths[${index}]`,
|
|
1081
|
+
value,
|
|
1082
|
+
intent: "read"
|
|
1083
|
+
});
|
|
1084
|
+
});
|
|
1085
|
+
return out;
|
|
1086
|
+
}
|
|
1087
|
+
},
|
|
1088
|
+
{
|
|
1089
|
+
name: "approval_request",
|
|
1090
|
+
description: "Ask a human a question before proceeding, optionally offering a fixed set of answers. Waits for a decision.",
|
|
1091
|
+
inputSchema: ApprovalRequestInput,
|
|
1092
|
+
effect: "control",
|
|
1093
|
+
terminal: false,
|
|
1094
|
+
pathArgsOf: noPaths
|
|
1095
|
+
},
|
|
1096
|
+
{
|
|
1097
|
+
name: "submit_result",
|
|
1098
|
+
description: "Submit the final structured result for this task. Call submit_result alone, last: no other tool call may follow it in the same batch.",
|
|
1099
|
+
inputSchema: AgentOutput,
|
|
1100
|
+
effect: "control",
|
|
1101
|
+
terminal: true,
|
|
1102
|
+
pathArgsOf: (input) => {
|
|
1103
|
+
const artifacts = arrayField(input, "artifacts");
|
|
1104
|
+
if (!artifacts) return [];
|
|
1105
|
+
const out = [];
|
|
1106
|
+
artifacts.forEach((artifact, index) => {
|
|
1107
|
+
const path = stringField(artifact, "path");
|
|
1108
|
+
if (path !== void 0) out.push({
|
|
1109
|
+
arg: `artifacts[${index}].path`,
|
|
1110
|
+
value: path,
|
|
1111
|
+
intent: "read"
|
|
1112
|
+
});
|
|
1113
|
+
});
|
|
1114
|
+
return out;
|
|
1115
|
+
}
|
|
1116
|
+
},
|
|
1117
|
+
{
|
|
1118
|
+
name: "git_commit",
|
|
1119
|
+
description: "Reserved: granted to nobody in V3.0. Cohorte alone commits agent work.",
|
|
1120
|
+
inputSchema: GitCommitInput,
|
|
1121
|
+
effect: "write",
|
|
1122
|
+
terminal: false,
|
|
1123
|
+
pathArgsOf: noPaths
|
|
1124
|
+
},
|
|
1125
|
+
{
|
|
1126
|
+
name: "network_request",
|
|
1127
|
+
description: "Reserved: granted to nobody in V3.0. Agents have no network access.",
|
|
1128
|
+
inputSchema: NetworkRequestInput,
|
|
1129
|
+
effect: "network",
|
|
1130
|
+
terminal: false,
|
|
1131
|
+
pathArgsOf: noPaths
|
|
1132
|
+
},
|
|
1133
|
+
{
|
|
1134
|
+
name: "secret_read",
|
|
1135
|
+
description: "Reserved: granted to nobody in V3.0.",
|
|
1136
|
+
inputSchema: SecretReadInput,
|
|
1137
|
+
effect: "read",
|
|
1138
|
+
terminal: false,
|
|
1139
|
+
pathArgsOf: noPaths
|
|
1140
|
+
}
|
|
1141
|
+
];
|
|
1142
|
+
CATALOGUE_ROWS.map((row) => row.name);
|
|
1143
|
+
//#endregion
|
|
1144
|
+
//#region ../../packages/tools/src/impl/exec/index.ts
|
|
1145
|
+
const metadata$1 = CATALOGUE_ROWS.find((row) => row.name === "run_command");
|
|
1146
|
+
if (!metadata$1) throw new Error("missing catalogue row: run_command");
|
|
1147
|
+
const fail$2 = (code, message) => {
|
|
1148
|
+
throw new Error(`${code}: ${message}`);
|
|
1149
|
+
};
|
|
1150
|
+
const runCommand = {
|
|
1151
|
+
...metadata$1,
|
|
1152
|
+
plan(input, n) {
|
|
1153
|
+
return {
|
|
1154
|
+
kind: "tool.run_command",
|
|
1155
|
+
replayClass: n.command?.replay ?? "at-most-once",
|
|
1156
|
+
verify: {
|
|
1157
|
+
tool: "run_command",
|
|
1158
|
+
input,
|
|
1159
|
+
normalized: JSON.parse(JSON.stringify(n))
|
|
1160
|
+
}
|
|
1161
|
+
};
|
|
1162
|
+
},
|
|
1163
|
+
async execute(input, n, ctx, signal) {
|
|
1164
|
+
if (signal.aborted) fail$2("tool-transient/cancelled", "command was cancelled");
|
|
1165
|
+
const command = n.command ?? fail$2("permission/command-not-allowed", "run_command has no normalized command");
|
|
1166
|
+
const value = input;
|
|
1167
|
+
const result = await ctx.executor.run({
|
|
1168
|
+
file: command.file,
|
|
1169
|
+
args: command.args,
|
|
1170
|
+
cwd: command.cwd,
|
|
1171
|
+
env: {},
|
|
1172
|
+
fs: {
|
|
1173
|
+
readWrite: [ctx.workspaceRoot],
|
|
1174
|
+
readOnly: [],
|
|
1175
|
+
denyRead: []
|
|
1176
|
+
},
|
|
1177
|
+
network: "none",
|
|
1178
|
+
timeoutMs: value.timeoutMs ?? command.timeoutMs,
|
|
1179
|
+
maxOutputBytes: 1048576,
|
|
1180
|
+
stdin: "ignore",
|
|
1181
|
+
limits: {},
|
|
1182
|
+
require: "best-effort"
|
|
1183
|
+
}, signal);
|
|
1184
|
+
const text = String(result.tail);
|
|
1185
|
+
return {
|
|
1186
|
+
output: {
|
|
1187
|
+
argv: value.argv,
|
|
1188
|
+
exitCode: result.exitCode,
|
|
1189
|
+
...result.signal === void 0 ? {} : { signal: result.signal },
|
|
1190
|
+
outcome: result.outcome,
|
|
1191
|
+
text,
|
|
1192
|
+
outputSha256: result.outputSha256,
|
|
1193
|
+
outputBytes: result.outputBytes,
|
|
1194
|
+
truncated: result.truncated
|
|
1195
|
+
},
|
|
1196
|
+
modelText: text,
|
|
1197
|
+
filesTouched: []
|
|
1198
|
+
};
|
|
1199
|
+
},
|
|
1200
|
+
verifyAfterCrash: async (_record) => "in-doubt",
|
|
1201
|
+
describeForNote: (record) => `run_command ${record.idempotencyKey}`
|
|
1202
|
+
};
|
|
1203
|
+
const EXEC_TOOLS = Object.freeze({ run_command: runCommand });
|
|
1204
|
+
//#endregion
|
|
1205
|
+
//#region ../../packages/tools/src/impl/read/index.ts
|
|
1206
|
+
const row$1 = (name) => {
|
|
1207
|
+
const entry = CATALOGUE_ROWS.find((candidate) => candidate.name === name);
|
|
1208
|
+
if (entry === void 0) throw new Error(`missing catalogue row: ${name}`);
|
|
1209
|
+
return entry;
|
|
1210
|
+
};
|
|
1211
|
+
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
1212
|
+
const result$1 = (output, modelText) => ({
|
|
1213
|
+
output,
|
|
1214
|
+
modelText,
|
|
1215
|
+
filesTouched: []
|
|
1216
|
+
});
|
|
1217
|
+
const fail$1 = (code, message) => {
|
|
1218
|
+
throw new Error(`${code}: ${message}`);
|
|
1219
|
+
};
|
|
1220
|
+
const requiredPath$1 = (value, message) => value ?? fail$1("permission/path-outside-grant", message);
|
|
1221
|
+
const make$1 = (name, execute) => {
|
|
1222
|
+
return {
|
|
1223
|
+
...row$1(name),
|
|
1224
|
+
plan: (input, n) => ({
|
|
1225
|
+
kind: "tool.read",
|
|
1226
|
+
replayClass: "idempotent",
|
|
1227
|
+
verify: {
|
|
1228
|
+
tool: name,
|
|
1229
|
+
input,
|
|
1230
|
+
normalized: JSON.parse(JSON.stringify(n))
|
|
1231
|
+
}
|
|
1232
|
+
}),
|
|
1233
|
+
execute,
|
|
1234
|
+
verifyAfterCrash: async (_record) => "done",
|
|
1235
|
+
describeForNote: (record) => `${name} ${record.idempotencyKey}`
|
|
1236
|
+
};
|
|
1237
|
+
};
|
|
1238
|
+
const readFileTool = make$1("read_file", async (input, n, _ctx, signal) => {
|
|
1239
|
+
if (signal.aborted) fail$1("tool-transient/cancelled", "read was cancelled");
|
|
1240
|
+
const target = requiredPath$1(n.paths[0]?.resolved.canonical, "read_file has no resolved path");
|
|
1241
|
+
const bytes = await readFile(target);
|
|
1242
|
+
if (bytes.byteLength > 262144) fail$1("tool-terminal/output-too-large", "file exceeds the 256 KiB read limit");
|
|
1243
|
+
if (bytes.includes(0)) fail$1("tool-terminal/binary-file", "binary files are not returned as text");
|
|
1244
|
+
const value = input;
|
|
1245
|
+
const offset = value.offset ?? 0;
|
|
1246
|
+
const selected = bytes.toString("utf8").split(/\r?\n/).slice(offset, value.limit === void 0 ? void 0 : offset + value.limit);
|
|
1247
|
+
const text = selected.map((line, index) => `${offset + index + 1}: ${line}`).join("\n");
|
|
1248
|
+
return result$1({
|
|
1249
|
+
path: n.paths[0]?.resolved.relative ?? value.path,
|
|
1250
|
+
text,
|
|
1251
|
+
sha256: sha256(bytes),
|
|
1252
|
+
offset,
|
|
1253
|
+
lines: selected.length
|
|
1254
|
+
}, text);
|
|
1255
|
+
});
|
|
1256
|
+
const walk = async (root, current, output, max) => {
|
|
1257
|
+
if (output.length >= max) return;
|
|
1258
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
1259
|
+
if (entry.name === ".git" || entry.name === ".cohorte" || entry.name === ".pi" || entry.isSymbolicLink()) continue;
|
|
1260
|
+
const absolute = join(current, entry.name);
|
|
1261
|
+
if (entry.isDirectory()) await walk(root, absolute, output, max);
|
|
1262
|
+
else output.push(relative(root, absolute).split("\\").join("/"));
|
|
1263
|
+
if (output.length >= max) return;
|
|
1264
|
+
}
|
|
1265
|
+
};
|
|
1266
|
+
const globMatch = (value, pattern) => {
|
|
1267
|
+
if (!pattern || pattern === "**") return true;
|
|
1268
|
+
const escaped = pattern.replace(/[.*+?^()|[\]\\]/g, "\\$&").replaceAll("\\*\\*", ".*").replaceAll("\\*", "[^/]*").replaceAll("\\?", "[^/]");
|
|
1269
|
+
return new RegExp(`^${escaped}$`).test(value);
|
|
1270
|
+
};
|
|
1271
|
+
const listFilesTool = make$1("list_files", async (input, n, _ctx, signal) => {
|
|
1272
|
+
if (signal.aborted) fail$1("tool-transient/cancelled", "listing was cancelled");
|
|
1273
|
+
const root = requiredPath$1(n.paths[0]?.resolved.canonical, "list_files has no resolved root");
|
|
1274
|
+
const value = input;
|
|
1275
|
+
const files = [];
|
|
1276
|
+
await walk(root, root, files, value.maxEntries ?? 1e3);
|
|
1277
|
+
const filtered = files.filter((file) => globMatch(file, value.glob));
|
|
1278
|
+
return result$1({
|
|
1279
|
+
files: filtered,
|
|
1280
|
+
truncated: files.length >= (value.maxEntries ?? 1e3)
|
|
1281
|
+
}, filtered.join("\n"));
|
|
1282
|
+
});
|
|
1283
|
+
const searchTool = make$1("search", async (input, n, _ctx, signal) => {
|
|
1284
|
+
if (signal.aborted) fail$1("tool-transient/cancelled", "search was cancelled");
|
|
1285
|
+
const root = requiredPath$1(n.paths[0]?.resolved.canonical, "search has no resolved root");
|
|
1286
|
+
const value = input;
|
|
1287
|
+
const expression = new RegExp(value.pattern, value.caseInsensitive ? "i" : "");
|
|
1288
|
+
const files = [];
|
|
1289
|
+
await walk(root, root, files, 1e4);
|
|
1290
|
+
const matches = [];
|
|
1291
|
+
for (const file of files.filter((candidate) => globMatch(candidate, value.glob))) {
|
|
1292
|
+
if (matches.length >= (value.maxMatches ?? 100)) break;
|
|
1293
|
+
const text = await readFile(join(root, file), "utf8").catch(() => void 0);
|
|
1294
|
+
if (text === void 0) continue;
|
|
1295
|
+
text.split(/\r?\n/).forEach((line, index) => {
|
|
1296
|
+
if (matches.length < (value.maxMatches ?? 100) && expression.test(line)) matches.push({
|
|
1297
|
+
path: file,
|
|
1298
|
+
line: index + 1,
|
|
1299
|
+
text: line
|
|
1300
|
+
});
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
return result$1({
|
|
1304
|
+
matches,
|
|
1305
|
+
filteredPaths: 0
|
|
1306
|
+
}, matches.map((match) => `${match.path}:${match.line}:${match.text}`).join("\n"));
|
|
1307
|
+
});
|
|
1308
|
+
const gitDiffTool = make$1("git_diff", async (input, _n, ctx, signal) => {
|
|
1309
|
+
if (signal.aborted) fail$1("tool-transient/cancelled", "diff was cancelled");
|
|
1310
|
+
const value = input;
|
|
1311
|
+
const output = await ctx.git.diffBySurface({
|
|
1312
|
+
repo: ctx.workspaceRoot,
|
|
1313
|
+
base: value.base ?? "integration",
|
|
1314
|
+
head: "HEAD",
|
|
1315
|
+
surfaces: { surfaceOf: () => "shared" }
|
|
1316
|
+
});
|
|
1317
|
+
const text = output.map((item) => new TextDecoder().decode(item.patch.bytes)).join("\\n");
|
|
1318
|
+
return result$1({
|
|
1319
|
+
base: value.base ?? "integration",
|
|
1320
|
+
stat: value.stat ?? false,
|
|
1321
|
+
files: output.flatMap((item) => item.files),
|
|
1322
|
+
diff: text
|
|
1323
|
+
}, text);
|
|
1324
|
+
});
|
|
1325
|
+
const READ_TOOLS = Object.freeze({
|
|
1326
|
+
read_file: readFileTool,
|
|
1327
|
+
list_files: listFilesTool,
|
|
1328
|
+
search: searchTool,
|
|
1329
|
+
git_diff: gitDiffTool
|
|
1330
|
+
});
|
|
1331
|
+
//#endregion
|
|
1332
|
+
//#region ../../packages/tools/src/catalogue/stub.ts
|
|
1333
|
+
const rowByName$1 = new Map(CATALOGUE_ROWS.map((row) => [row.name, row]));
|
|
1334
|
+
/**
|
|
1335
|
+
* Every deliverable of THIS unit (U0.08) ends here: a `ToolImplementation` whose `plan`/`execute`/`verifyAfterCrash`
|
|
1336
|
+
* /`describeForNote` throw. The metadata (`name`, `inputSchema`, `description`, `effect`, `terminal`) is real —
|
|
1337
|
+
* `impl/read`, `impl/write`, `impl/exec` and `impl/state` (Wave 2: `U2.03`, `U2.04`) call this for every tool they
|
|
1338
|
+
* do not yet implement, and REPLACE the body, never the metadata, one tool at a time.
|
|
1339
|
+
*/
|
|
1340
|
+
function stubImplementation(name) {
|
|
1341
|
+
const row = rowByName$1.get(name);
|
|
1342
|
+
if (!row) throw new RangeError(`stubImplementation: unknown tool ${JSON.stringify(name)}`);
|
|
1343
|
+
const notImplemented = (member) => {
|
|
1344
|
+
throw new NotImplemented(`tools/${name}.${member} (Wave 2 fills packages/tools/src/impl/**)`);
|
|
1345
|
+
};
|
|
1346
|
+
return {
|
|
1347
|
+
name: row.name,
|
|
1348
|
+
inputSchema: row.inputSchema,
|
|
1349
|
+
description: row.description,
|
|
1350
|
+
effect: row.effect,
|
|
1351
|
+
terminal: row.terminal,
|
|
1352
|
+
plan() {
|
|
1353
|
+
return notImplemented("plan");
|
|
1354
|
+
},
|
|
1355
|
+
execute() {
|
|
1356
|
+
return notImplemented("execute");
|
|
1357
|
+
},
|
|
1358
|
+
verifyAfterCrash() {
|
|
1359
|
+
return notImplemented("verifyAfterCrash");
|
|
1360
|
+
},
|
|
1361
|
+
describeForNote() {
|
|
1362
|
+
return notImplemented("describeForNote");
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
}
|
|
1366
|
+
//#endregion
|
|
1367
|
+
//#region ../../packages/tools/src/impl/state/index.ts
|
|
1368
|
+
const metadata = (name) => {
|
|
1369
|
+
const row = CATALOGUE_ROWS.find((candidate) => candidate.name === name);
|
|
1370
|
+
if (row === void 0) throw new Error(`missing catalogue row: ${name}`);
|
|
1371
|
+
return row;
|
|
1372
|
+
};
|
|
1373
|
+
const approvalRequest = {
|
|
1374
|
+
...metadata("approval_request"),
|
|
1375
|
+
plan: () => null,
|
|
1376
|
+
execute: async (input, _normalized, ctx) => {
|
|
1377
|
+
const value = input;
|
|
1378
|
+
const decision = await ctx.requestApproval(value.question, value.options);
|
|
1379
|
+
return {
|
|
1380
|
+
output: decision,
|
|
1381
|
+
modelText: decision.answer ?? decision.decision,
|
|
1382
|
+
filesTouched: []
|
|
1383
|
+
};
|
|
1384
|
+
},
|
|
1385
|
+
verifyAfterCrash: async () => "done",
|
|
1386
|
+
describeForNote: () => "approval request"
|
|
1387
|
+
};
|
|
1388
|
+
const submitResult = {
|
|
1389
|
+
...metadata("submit_result"),
|
|
1390
|
+
plan: () => null,
|
|
1391
|
+
execute: async (input, _normalized, ctx) => {
|
|
1392
|
+
const accepted = await ctx.acceptResult(input);
|
|
1393
|
+
if (!accepted.accepted) throw new Error(`validation/agent-output: ${accepted.reason ?? "result was rejected"}`);
|
|
1394
|
+
return {
|
|
1395
|
+
output: { accepted: true },
|
|
1396
|
+
modelText: "result submitted",
|
|
1397
|
+
filesTouched: []
|
|
1398
|
+
};
|
|
1399
|
+
},
|
|
1400
|
+
verifyAfterCrash: async () => "done",
|
|
1401
|
+
describeForNote: () => "submit result"
|
|
1402
|
+
};
|
|
1403
|
+
const STATE_TOOLS = Object.freeze({
|
|
1404
|
+
approval_request: approvalRequest,
|
|
1405
|
+
submit_result: submitResult,
|
|
1406
|
+
git_commit: stubImplementation("git_commit"),
|
|
1407
|
+
network_request: stubImplementation("network_request"),
|
|
1408
|
+
secret_read: stubImplementation("secret_read")
|
|
1409
|
+
});
|
|
1410
|
+
//#endregion
|
|
1411
|
+
//#region ../../packages/tools/src/impl/write/index.ts
|
|
1412
|
+
const row = (name) => {
|
|
1413
|
+
const entry = CATALOGUE_ROWS.find((candidate) => candidate.name === name);
|
|
1414
|
+
if (entry === void 0) throw new Error(`missing catalogue row: ${name}`);
|
|
1415
|
+
return entry;
|
|
1416
|
+
};
|
|
1417
|
+
const digest = (value) => sha256Hex(createHash("sha256").update(value).digest());
|
|
1418
|
+
const fail = (code, message) => {
|
|
1419
|
+
throw new Error(`${code}: ${message}`);
|
|
1420
|
+
};
|
|
1421
|
+
const requiredPath = (n) => n.paths[0]?.resolved.canonical ?? fail("permission/path-outside-grant", "write tool has no resolved path");
|
|
1422
|
+
const result = (output, modelText, filesTouched) => ({
|
|
1423
|
+
output,
|
|
1424
|
+
modelText,
|
|
1425
|
+
filesTouched
|
|
1426
|
+
});
|
|
1427
|
+
const make = (name, execute) => {
|
|
1428
|
+
return {
|
|
1429
|
+
...row(name),
|
|
1430
|
+
plan: (input, _n) => ({
|
|
1431
|
+
kind: name === "write_file" ? "tool.write_file" : "tool.patch_file",
|
|
1432
|
+
replayClass: "verifiable",
|
|
1433
|
+
verify: {
|
|
1434
|
+
tool: name,
|
|
1435
|
+
input
|
|
1436
|
+
}
|
|
1437
|
+
}),
|
|
1438
|
+
execute,
|
|
1439
|
+
verifyAfterCrash: async (record, ctx) => {
|
|
1440
|
+
const request = record.request;
|
|
1441
|
+
if (!request.path) return "in-doubt";
|
|
1442
|
+
try {
|
|
1443
|
+
const path = ctx.paths.resolve(request.path, ctx.workspaceRoot, "read");
|
|
1444
|
+
if (!path.ok) return "in-doubt";
|
|
1445
|
+
const bytes = await readFile(path.value.canonical);
|
|
1446
|
+
return request.content !== void 0 && digest(bytes) === digest(request.content) ? "done" : "not-done";
|
|
1447
|
+
} catch {
|
|
1448
|
+
return "not-done";
|
|
1449
|
+
}
|
|
1450
|
+
},
|
|
1451
|
+
describeForNote: (record) => `${name} ${record.idempotencyKey}`
|
|
1452
|
+
};
|
|
1453
|
+
};
|
|
1454
|
+
const atomicWrite = async (target, content) => {
|
|
1455
|
+
const temporary = join(dirname(target), `.cohorte-write-${process.pid}-${Date.now()}`);
|
|
1456
|
+
await writeFile(temporary, content, {
|
|
1457
|
+
encoding: "utf8",
|
|
1458
|
+
flag: "wx"
|
|
1459
|
+
});
|
|
1460
|
+
await rename(temporary, target);
|
|
1461
|
+
};
|
|
1462
|
+
const writeFileTool = make("write_file", async (input, n, _ctx, signal) => {
|
|
1463
|
+
if (signal.aborted) fail("tool-transient/cancelled", "write was cancelled");
|
|
1464
|
+
const target = requiredPath(n);
|
|
1465
|
+
const value = input;
|
|
1466
|
+
let before;
|
|
1467
|
+
try {
|
|
1468
|
+
before = await readFile(target);
|
|
1469
|
+
} catch {}
|
|
1470
|
+
await atomicWrite(target, value.content);
|
|
1471
|
+
const after = Buffer.from(value.content);
|
|
1472
|
+
const path = n.paths[0]?.resolved.relative ?? value.path;
|
|
1473
|
+
return result({
|
|
1474
|
+
path,
|
|
1475
|
+
bytes: after.byteLength,
|
|
1476
|
+
sha256: digest(after)
|
|
1477
|
+
}, `wrote ${after.byteLength} bytes`, [{
|
|
1478
|
+
path,
|
|
1479
|
+
op: before === void 0 ? "create" : "modify",
|
|
1480
|
+
...before === void 0 ? {} : { beforeSha256: digest(before) },
|
|
1481
|
+
afterSha256: digest(after),
|
|
1482
|
+
bytes: after.byteLength
|
|
1483
|
+
}]);
|
|
1484
|
+
});
|
|
1485
|
+
const patchFileTool = make("patch_file", async (input, n, _ctx, signal) => {
|
|
1486
|
+
if (signal.aborted) fail("tool-transient/cancelled", "patch was cancelled");
|
|
1487
|
+
const target = requiredPath(n);
|
|
1488
|
+
const value = input;
|
|
1489
|
+
const before = await readFile(target, "utf8");
|
|
1490
|
+
let next = before;
|
|
1491
|
+
for (const edit of value.edits) {
|
|
1492
|
+
const count = next.split(edit.oldText).length - 1;
|
|
1493
|
+
if (count !== 1) fail("tool-terminal/patch-mismatch", `expected one occurrence, found ${count}`);
|
|
1494
|
+
next = next.replace(edit.oldText, edit.newText);
|
|
1495
|
+
}
|
|
1496
|
+
await atomicWrite(target, next);
|
|
1497
|
+
const after = Buffer.from(next);
|
|
1498
|
+
const path = n.paths[0]?.resolved.relative ?? value.path;
|
|
1499
|
+
return result({
|
|
1500
|
+
path,
|
|
1501
|
+
bytes: after.byteLength,
|
|
1502
|
+
sha256: digest(after),
|
|
1503
|
+
edits: value.edits.length
|
|
1504
|
+
}, `patched ${path}`, [{
|
|
1505
|
+
path,
|
|
1506
|
+
op: "modify",
|
|
1507
|
+
beforeSha256: digest(Buffer.from(before)),
|
|
1508
|
+
afterSha256: digest(after),
|
|
1509
|
+
bytes: after.byteLength
|
|
1510
|
+
}]);
|
|
1511
|
+
});
|
|
1512
|
+
const WRITE_TOOLS = Object.freeze({
|
|
1513
|
+
write_file: writeFileTool,
|
|
1514
|
+
patch_file: patchFileTool
|
|
1515
|
+
});
|
|
1516
|
+
//#endregion
|
|
1517
|
+
//#region ../../packages/tools/src/catalogue/index.ts
|
|
1518
|
+
const rowByName = new Map(CATALOGUE_ROWS.map((row) => [row.name, row]));
|
|
1519
|
+
/** `ToolIntrospection` is on the per-tool-call path (PLAN PC-4: the gate validates a call's shape without importing
|
|
1520
|
+
* `tools`), so the strict schema of every row is built ONCE, here: `schemaOf` returns the same object identity on
|
|
1521
|
+
* every call and a validator cache keyed by schema identity in the gate (U2.01/U2.02) keeps working. */
|
|
1522
|
+
const strictSchemaByName = new Map(CATALOGUE_ROWS.map((row) => [row.name, toStrictSchema(row.inputSchema)]));
|
|
1523
|
+
Compile(ToolGrant);
|
|
1524
|
+
const namingProblems = toolGrantProblems(CATALOGUE_ROWS.map((row) => ({ tool: row.name })));
|
|
1525
|
+
if (namingProblems.length > 0) throw new TypeError(`tool catalogue: ${namingProblems.join("; ")}`);
|
|
1526
|
+
/** Catalogue-derived: never a second, hand-written copy of what a tool's input schema says (PLAN PC-4: `security`
|
|
1527
|
+
* validates a call's shape and path arguments without importing `tools`). */
|
|
1528
|
+
const toolIntrospection = {
|
|
1529
|
+
schemaOf(tool) {
|
|
1530
|
+
return strictSchemaByName.get(tool);
|
|
1531
|
+
},
|
|
1532
|
+
pathArgsOf(tool, input) {
|
|
1533
|
+
const row = rowByName.get(tool);
|
|
1534
|
+
if (!row) return [];
|
|
1535
|
+
return row.pathArgsOf(input);
|
|
1536
|
+
}
|
|
1537
|
+
};
|
|
1538
|
+
/** The 9 V3.0 tools + the 3 seams, by name (DESIGN 2.7). Every value is `stubImplementation(name)` until Wave 2
|
|
1539
|
+
* replaces the bodies in `impl/**`; the metadata is already the catalogue's own. */
|
|
1540
|
+
const TOOL_CATALOGUE = Object.freeze({
|
|
1541
|
+
...READ_TOOLS,
|
|
1542
|
+
...WRITE_TOOLS,
|
|
1543
|
+
...EXEC_TOOLS,
|
|
1544
|
+
...STATE_TOOLS
|
|
1545
|
+
});
|
|
1546
|
+
//#endregion
|
|
1547
|
+
export { buildPolicySnapshot as a, createCommandPolicy as c, createGlobMatcher as i, createProgramResolver as l, toolIntrospection as n, createPolicyEngine as o, createPathResolver as r, explainPolicy as s, TOOL_CATALOGUE as t };
|
|
1548
|
+
|
|
1549
|
+
//# sourceMappingURL=catalogue-BvwSrM_j.mjs.map
|