taskboy 0.1.1__py3-none-any.whl
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.
- taskboy/__init__.py +0 -0
- taskboy/adapters/__init__.py +0 -0
- taskboy/adapters/_util.py +30 -0
- taskboy/adapters/aws_read.py +107 -0
- taskboy/adapters/confluence.py +88 -0
- taskboy/adapters/github_api.py +564 -0
- taskboy/adapters/issues.py +435 -0
- taskboy/adapters/jira.py +263 -0
- taskboy/adapters/sentry.py +82 -0
- taskboy/adapters/slack_history.py +204 -0
- taskboy/assets.py +27 -0
- taskboy/audit.py +78 -0
- taskboy/broker.py +266 -0
- taskboy/classifier.py +219 -0
- taskboy/cli.py +161 -0
- taskboy/config.py +539 -0
- taskboy/dashboard/__init__.py +5 -0
- taskboy/dashboard/api.py +963 -0
- taskboy/dashboard/app.py +78 -0
- taskboy/dashboard/auth.py +117 -0
- taskboy/dashboard/editors.py +140 -0
- taskboy/dashboard/gitops.py +60 -0
- taskboy/dashboard/render.py +48 -0
- taskboy/debug_feed.py +170 -0
- taskboy/deploy/env.example +17 -0
- taskboy/deploy/git-cred-helper.py +36 -0
- taskboy/deploy/install.sh +101 -0
- taskboy/deploy/remote-update.sh +52 -0
- taskboy/deploy/taskboy-restart.path +10 -0
- taskboy/deploy/taskboy-restart.service +8 -0
- taskboy/deploy/taskboy.service +22 -0
- taskboy/hooks.py +208 -0
- taskboy/issue_runs.py +203 -0
- taskboy/llm.py +125 -0
- taskboy/main.py +269 -0
- taskboy/memory.py +59 -0
- taskboy/models.py +103 -0
- taskboy/mrkdwn.py +19 -0
- taskboy/notify.py +41 -0
- taskboy/orchestrator.py +401 -0
- taskboy/personality.py +17 -0
- taskboy/prompts.py +288 -0
- taskboy/quick.py +234 -0
- taskboy/redact.py +49 -0
- taskboy/repocache.py +117 -0
- taskboy/review_requests.py +388 -0
- taskboy/router.py +146 -0
- taskboy/runner.py +581 -0
- taskboy/scheduler.py +301 -0
- taskboy/secrets.py +83 -0
- taskboy/settings.py +19 -0
- taskboy/setup_checks.py +184 -0
- taskboy/setup_wizard.py +699 -0
- taskboy/skills.py +95 -0
- taskboy/slack.py +666 -0
- taskboy/slack_users.py +32 -0
- taskboy/started_messages.py +33 -0
- taskboy/store.py +1393 -0
- taskboy/task_actions.py +86 -0
- taskboy/templates/config.example.yaml +149 -0
- taskboy/templates/conventions.md +60 -0
- taskboy/templates/help.example.md +29 -0
- taskboy/templates/personality_agent.example.md +5 -0
- taskboy/templates/personality_reviewer.example.md +5 -0
- taskboy/templates/services/aws.yaml +7 -0
- taskboy/templates/services/confluence.yaml +4 -0
- taskboy/templates/services/github.yaml +14 -0
- taskboy/templates/services/jira.yaml +6 -0
- taskboy/templates/services/sentry.yaml +4 -0
- taskboy/templates/services/slack.yaml +9 -0
- taskboy/templates/skills/README.md +33 -0
- taskboy/templates/skills/discoverissues/SKILL.md +46 -0
- taskboy/templates/skills/implementapprovedissues/SKILL.md +41 -0
- taskboy/templates/skills/jira2pr/SKILL.md +66 -0
- taskboy/templates/skills/monitor/SKILL.md +32 -0
- taskboy/templates/skills/monitornew/SKILL.md +27 -0
- taskboy/templates/skills/refineissue/SKILL.md +35 -0
- taskboy/templates/skills/release/SKILL.md +36 -0
- taskboy/templates/skills/review/SKILL.md +89 -0
- taskboy/templates/skills/reviewandmonitor/SKILL.md +22 -0
- taskboy/templates/skills/reviews/SKILL.md +26 -0
- taskboy/templates/skills/slack2jira/SKILL.md +47 -0
- taskboy/templates/skills/slack2pr/SKILL.md +64 -0
- taskboy/templates/skills/spec2pr/SKILL.md +35 -0
- taskboy/templates/slack_app_manifest.yaml +48 -0
- taskboy/templates/task_started_messages.yaml +14 -0
- taskboy/ui_dist/assets/index-DmYxR9Qy.css +1 -0
- taskboy/ui_dist/assets/index-LDDO4iT2.js +15 -0
- taskboy/ui_dist/index.html +15 -0
- taskboy/workspace.py +86 -0
- taskboy-0.1.1.dist-info/METADATA +120 -0
- taskboy-0.1.1.dist-info/RECORD +95 -0
- taskboy-0.1.1.dist-info/WHEEL +5 -0
- taskboy-0.1.1.dist-info/entry_points.txt +2 -0
- taskboy-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# host bootstrap for the taskboy host (amazon linux 2023). idempotent; run as root.
|
|
3
|
+
# usage: sudo ./install.sh [pip-spec]
|
|
4
|
+
# pip-spec: what to install into the venv — "taskboy==X.Y.Z" (recommended: your shell
|
|
5
|
+
# repo's pinned version), a wheel path, or a checkout directory. defaults to the latest
|
|
6
|
+
# taskboy release on PyPI.
|
|
7
|
+
# this script ships inside the package; to bootstrap a fresh host without a checkout:
|
|
8
|
+
# python3.12 -m venv /tmp/bootstrap && /tmp/bootstrap/bin/pip install "taskboy==X.Y.Z"
|
|
9
|
+
# /tmp/bootstrap/bin/taskboy assets deploy /tmp && sudo bash /tmp/deploy/install.sh "taskboy==X.Y.Z"
|
|
10
|
+
# routine releases do NOT re-run this script; ci invokes remote-update.sh via ssm.
|
|
11
|
+
# re-run this only for host-level changes (users, docker, iptables, units).
|
|
12
|
+
set -euo pipefail
|
|
13
|
+
|
|
14
|
+
# fixed install layout — not operator knobs
|
|
15
|
+
SLUG="taskboy"
|
|
16
|
+
SVC_USER="taskboy"
|
|
17
|
+
OPT_DIR="/opt/$SLUG"
|
|
18
|
+
ETC_DIR="/etc/$SLUG"
|
|
19
|
+
VAR_DIR="/var/lib/$SLUG"
|
|
20
|
+
RUN_DIR="/run/$SLUG"
|
|
21
|
+
|
|
22
|
+
PKG="${1:-taskboy}"
|
|
23
|
+
|
|
24
|
+
echo "== packages =="
|
|
25
|
+
dnf install -y -q docker git python3.12 iptables-nft
|
|
26
|
+
|
|
27
|
+
echo "== service user + directories =="
|
|
28
|
+
id -u "$SVC_USER" &>/dev/null || useradd --system --home-dir "$VAR_DIR" --shell /sbin/nologin "$SVC_USER"
|
|
29
|
+
install -d -o "$SVC_USER" -g "$SVC_USER" -m 700 "$VAR_DIR" "$VAR_DIR/workspaces" "$VAR_DIR/memory" "$VAR_DIR/repos"
|
|
30
|
+
install -d -o "$SVC_USER" -g "$SVC_USER" -m 755 "$RUN_DIR"
|
|
31
|
+
install -d -m 755 "$ETC_DIR" "$OPT_DIR"
|
|
32
|
+
echo "d $RUN_DIR 0755 $SVC_USER $SVC_USER" > "/etc/tmpfiles.d/$SLUG.conf"
|
|
33
|
+
|
|
34
|
+
echo "== code + venv =="
|
|
35
|
+
# the application is a pip-installed package; no source checkout lives on the host
|
|
36
|
+
python3.12 -m venv "$OPT_DIR/.venv"
|
|
37
|
+
"$OPT_DIR/.venv/bin/pip" install -q --upgrade pip
|
|
38
|
+
"$OPT_DIR/.venv/bin/pip" install -q --upgrade "$PKG"
|
|
39
|
+
install -d -o "$SVC_USER" -g "$SVC_USER" "$OPT_DIR/skills"
|
|
40
|
+
chown -R "$SVC_USER:$SVC_USER" "$OPT_DIR/.venv"
|
|
41
|
+
# note: the claude cli is bundled inside the claude-agent-sdk package — no separate install
|
|
42
|
+
|
|
43
|
+
echo "== packaged host files =="
|
|
44
|
+
assets_dir=$(mktemp -d)
|
|
45
|
+
trap 'rm -rf "$assets_dir"' EXIT
|
|
46
|
+
"$OPT_DIR/.venv/bin/taskboy" assets deploy "$assets_dir" > /dev/null
|
|
47
|
+
"$OPT_DIR/.venv/bin/taskboy" assets templates "$assets_dir" > /dev/null
|
|
48
|
+
# host copies of the deploy scripts — ci invokes remote-update.sh via ssm on every release
|
|
49
|
+
install -d "$OPT_DIR/deploy"
|
|
50
|
+
install -m 755 "$assets_dir/deploy/install.sh" "$OPT_DIR/deploy/install.sh"
|
|
51
|
+
install -m 755 "$assets_dir/deploy/remote-update.sh" "$OPT_DIR/deploy/remote-update.sh"
|
|
52
|
+
|
|
53
|
+
echo "== config =="
|
|
54
|
+
[ -f "$ETC_DIR/env" ] || install -m 640 -g "$SVC_USER" "$assets_dir/deploy/env.example" "$ETC_DIR/env"
|
|
55
|
+
# first install: seed operator config from the packaged example; /etc/taskboy is the
|
|
56
|
+
# live copy from then on. your shell repo's deploy workflow ships config updates via
|
|
57
|
+
# remote-update.sh; releases never overwrite what's here.
|
|
58
|
+
[ -f "$ETC_DIR/config.yaml" ] || install -m 640 -g "$SVC_USER" "$assets_dir/templates/config.example.yaml" "$ETC_DIR/config.yaml"
|
|
59
|
+
[ -f "$ETC_DIR/task_started_messages.yaml" ] || install -m 640 -g "$SVC_USER" "$assets_dir/templates/task_started_messages.yaml" "$ETC_DIR/task_started_messages.yaml"
|
|
60
|
+
|
|
61
|
+
echo "== task isolation prep =="
|
|
62
|
+
# slot users for sub-agent process separation (activated when the runner moves to systemd-run scopes)
|
|
63
|
+
for i in 0 1 2 3 4 5; do
|
|
64
|
+
id -u "$SVC_USER-t$i" &>/dev/null || useradd --system --no-create-home --shell /sbin/nologin "$SVC_USER-t$i"
|
|
65
|
+
done
|
|
66
|
+
|
|
67
|
+
echo "== docker =="
|
|
68
|
+
systemctl enable --now docker
|
|
69
|
+
usermod -aG docker "$SVC_USER"
|
|
70
|
+
for i in 0 1 2 3 4 5; do
|
|
71
|
+
usermod -aG docker "$SVC_USER-t$i"
|
|
72
|
+
done
|
|
73
|
+
install -d -m 755 /usr/libexec/docker/cli-plugins
|
|
74
|
+
if [ ! -x /usr/libexec/docker/cli-plugins/docker-compose ]; then
|
|
75
|
+
compose_tmp="$(mktemp)"
|
|
76
|
+
curl -fsSL "https://github.com/docker/compose/releases/download/v2.27.0/docker-compose-linux-$(uname -m)" -o "$compose_tmp"
|
|
77
|
+
install -m 755 "$compose_tmp" /usr/libexec/docker/cli-plugins/docker-compose
|
|
78
|
+
rm -f "$compose_tmp"
|
|
79
|
+
fi
|
|
80
|
+
# block the instance metadata service for task slot users; the orchestrator keeps access (§8.3)
|
|
81
|
+
for i in 0 1 2 3 4 5; do
|
|
82
|
+
iptables -C OUTPUT -d 169.254.169.254 -m owner --uid-owner "$SVC_USER-t$i" -j REJECT 2>/dev/null ||
|
|
83
|
+
iptables -A OUTPUT -d 169.254.169.254 -m owner --uid-owner "$SVC_USER-t$i" -j REJECT
|
|
84
|
+
done
|
|
85
|
+
|
|
86
|
+
echo "== systemd unit =="
|
|
87
|
+
install -m 644 "$assets_dir/deploy/$SLUG.service" "/etc/systemd/system/$SLUG.service"
|
|
88
|
+
# off-peak cli auto-update restart path: the service runs unprivileged (NoNewPrivileges=yes) so it can't
|
|
89
|
+
# sudo/systemctl-restart itself; instead it touches $RUN_DIR/restart-requested and this root-owned
|
|
90
|
+
# path unit restarts it (config cli_update:, taskboy/scheduler.py). replaces the old sudoers drop-in.
|
|
91
|
+
install -m 644 "$assets_dir/deploy/$SLUG-restart.service" "/etc/systemd/system/$SLUG-restart.service"
|
|
92
|
+
install -m 644 "$assets_dir/deploy/$SLUG-restart.path" "/etc/systemd/system/$SLUG-restart.path"
|
|
93
|
+
rm -f "/etc/sudoers.d/$SVC_USER-cli-update" # remove the old, NoNewPrivileges-incompatible sudo rule if present
|
|
94
|
+
systemctl daemon-reload
|
|
95
|
+
systemctl enable "$SLUG"
|
|
96
|
+
systemctl enable --now "$SLUG-restart.path"
|
|
97
|
+
|
|
98
|
+
echo "== done. next steps =="
|
|
99
|
+
echo " 1) put secrets in aws secrets manager (TASKBOY_SECRETS_<ENV>), or export them in $ETC_DIR/env for a no-aws install"
|
|
100
|
+
echo " 2) edit $ETC_DIR/config.yaml (or ship your shell repo's config via its deploy workflow)"
|
|
101
|
+
echo " 3) systemctl start $SLUG && journalctl -fu $SLUG"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# host-side deploy step, invoked by the shell repo's ci via ssm run command:
|
|
3
|
+
# pip-upgrade the venv from pypi, optionally sync operator config, refresh the
|
|
4
|
+
# packaged host files, restart. restarts are safe — running tasks requeue and
|
|
5
|
+
# resume (REL-002/003). host-level bootstrap changes (users, docker, iptables)
|
|
6
|
+
# still need install.sh.
|
|
7
|
+
# usage: remote-update.sh <pip-spec> [config-bundle-s3-url]
|
|
8
|
+
# pip-spec: e.g. "taskboy==X.Y.Z" (the shell repo's pin)
|
|
9
|
+
# config-bundle-s3-url: optional s3 url of a tarball with config/ and skills/
|
|
10
|
+
# directories; config/ syncs to /etc/taskboy and
|
|
11
|
+
# skills/ to /opt/taskboy/skills (copy-over, no deletes)
|
|
12
|
+
set -euo pipefail
|
|
13
|
+
|
|
14
|
+
pip_spec="$1"
|
|
15
|
+
config_url="${2:-}"
|
|
16
|
+
workdir=$(mktemp -d /tmp/taskboy-deploy.XXXXXX)
|
|
17
|
+
trap 'rm -rf "$workdir"' EXIT
|
|
18
|
+
|
|
19
|
+
echo "== installing $pip_spec =="
|
|
20
|
+
/opt/taskboy/.venv/bin/pip install -q --upgrade "$pip_spec"
|
|
21
|
+
|
|
22
|
+
if [ -n "$config_url" ]; then
|
|
23
|
+
echo "== syncing config from $config_url =="
|
|
24
|
+
aws s3 cp "$config_url" "$workdir/config.tgz" --only-show-errors
|
|
25
|
+
mkdir "$workdir/bundle"
|
|
26
|
+
tar xzf "$workdir/config.tgz" -C "$workdir/bundle"
|
|
27
|
+
if [ -d "$workdir/bundle/config" ]; then
|
|
28
|
+
find "$workdir/bundle/config" -type f -exec chmod 640 {} +
|
|
29
|
+
cp -R "$workdir/bundle/config/." /etc/taskboy/
|
|
30
|
+
find /etc/taskboy -type f -exec chown "root:taskboy" {} + -exec chmod 640 {} +
|
|
31
|
+
fi
|
|
32
|
+
if [ -d "$workdir/bundle/skills" ]; then
|
|
33
|
+
cp -R "$workdir/bundle/skills/." /opt/taskboy/skills/
|
|
34
|
+
chown -R "taskboy:taskboy" /opt/taskboy/skills
|
|
35
|
+
fi
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
echo "== refreshing packaged host files =="
|
|
39
|
+
/opt/taskboy/.venv/bin/taskboy assets deploy "$workdir" > /dev/null
|
|
40
|
+
install -m 644 "$workdir/deploy/taskboy.service" /etc/systemd/system/taskboy.service
|
|
41
|
+
install -m 644 "$workdir/deploy/taskboy-restart.service" /etc/systemd/system/taskboy-restart.service
|
|
42
|
+
install -m 644 "$workdir/deploy/taskboy-restart.path" /etc/systemd/system/taskboy-restart.path
|
|
43
|
+
# the running copy keeps its inode; the new script takes effect next invocation
|
|
44
|
+
install -m 755 "$workdir/deploy/install.sh" /opt/taskboy/deploy/install.sh
|
|
45
|
+
install -m 755 "$workdir/deploy/remote-update.sh" /opt/taskboy/deploy/remote-update.sh
|
|
46
|
+
systemctl daemon-reload
|
|
47
|
+
|
|
48
|
+
echo "== restarting =="
|
|
49
|
+
systemctl restart taskboy
|
|
50
|
+
sleep 5
|
|
51
|
+
systemctl is-active taskboy
|
|
52
|
+
echo "deploy ok: $pip_spec"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=watch for taskboy cli auto-update restart requests
|
|
3
|
+
|
|
4
|
+
[Path]
|
|
5
|
+
# the sandboxed service (NoNewPrivileges=yes) can't restart itself; it touches this flag and we act on it
|
|
6
|
+
PathExists=/run/taskboy/restart-requested
|
|
7
|
+
Unit=taskboy-restart.service
|
|
8
|
+
|
|
9
|
+
[Install]
|
|
10
|
+
WantedBy=multi-user.target
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=restart taskboy after an off-peak cli auto-update
|
|
3
|
+
|
|
4
|
+
[Service]
|
|
5
|
+
Type=oneshot
|
|
6
|
+
# clear the flag first so the .path unit re-arms for the next update, then restart the service
|
|
7
|
+
ExecStartPre=/usr/bin/rm -f /run/taskboy/restart-requested
|
|
8
|
+
ExecStart=/usr/bin/systemctl restart taskboy.service
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=taskboy orchestrator
|
|
3
|
+
After=network-online.target
|
|
4
|
+
Wants=network-online.target
|
|
5
|
+
|
|
6
|
+
[Service]
|
|
7
|
+
User=taskboy
|
|
8
|
+
Group=taskboy
|
|
9
|
+
WorkingDirectory=/opt/taskboy
|
|
10
|
+
EnvironmentFile=/etc/taskboy/env
|
|
11
|
+
ExecStart=/opt/taskboy/.venv/bin/python -m taskboy.main
|
|
12
|
+
# restarts are safe by design: reconciliation requeues and resumes interrupted tasks
|
|
13
|
+
Restart=on-failure
|
|
14
|
+
RestartSec=5
|
|
15
|
+
# invalid config exits 64: stop instead of crash-looping
|
|
16
|
+
RestartPreventExitStatus=64
|
|
17
|
+
TimeoutStopSec=90
|
|
18
|
+
NoNewPrivileges=yes
|
|
19
|
+
PrivateTmp=yes
|
|
20
|
+
|
|
21
|
+
[Install]
|
|
22
|
+
WantedBy=multi-user.target
|
taskboy/hooks.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""tool policy + audit for sub-agent sessions (TOL-004/005, §8.4).
|
|
2
|
+
|
|
3
|
+
pure functions decide; the TaskHooks callbacks apply them inside a session. platform
|
|
4
|
+
enforcement (github rulesets, IAM, jira permissions) is always the real control — this
|
|
5
|
+
layer is defense-in-depth and the audit feed. every denial is recorded (§10).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from claude_agent_sdk import PermissionResultAllow, PermissionResultDeny
|
|
11
|
+
|
|
12
|
+
from taskboy.models import Task
|
|
13
|
+
from taskboy.store import Store
|
|
14
|
+
|
|
15
|
+
# read/write classification per tool; unknown tools are conservatively write (TOL-005)
|
|
16
|
+
TOOL_CLASSIFICATION = {
|
|
17
|
+
"Read": "read",
|
|
18
|
+
"Grep": "read",
|
|
19
|
+
"Glob": "read",
|
|
20
|
+
"WebFetch": "read",
|
|
21
|
+
"WebSearch": "read",
|
|
22
|
+
"ToolSearch": "read", # loads deferred tool schemas only; per-tool allowlisting still gates execution (#94)
|
|
23
|
+
"Bash": "write", # conservative: bash can do anything
|
|
24
|
+
"Write": "write",
|
|
25
|
+
"Edit": "write",
|
|
26
|
+
"NotebookEdit": "write",
|
|
27
|
+
"mcp__harness__report_progress": "read",
|
|
28
|
+
"mcp__harness__report_blocked": "read",
|
|
29
|
+
"mcp__harness__request_permission": "read",
|
|
30
|
+
"mcp__harness__ask_questions": "read",
|
|
31
|
+
"mcp__issues__list_task_feedback": "read",
|
|
32
|
+
"mcp__issues__list_failed_tasks": "read",
|
|
33
|
+
"mcp__issues__list_recent_errors": "read",
|
|
34
|
+
"mcp__issues__record_issue": "write",
|
|
35
|
+
"mcp__issues__list_accepted_issues": "read",
|
|
36
|
+
"mcp__issues__list_existing_issues": "read",
|
|
37
|
+
"mcp__issues__get_issue": "read",
|
|
38
|
+
"mcp__issues__update_issue": "write",
|
|
39
|
+
"mcp__issues__list_issue_comments": "read",
|
|
40
|
+
"mcp__issues__post_issue_comment": "write",
|
|
41
|
+
"mcp__issues__update_issue_comment": "write",
|
|
42
|
+
"mcp__issues__delete_issue_comment": "write",
|
|
43
|
+
"mcp__issues__resolve_issue_comment": "write",
|
|
44
|
+
"mcp__issues__finish_issue": "write",
|
|
45
|
+
"mcp__enqueue__enqueue_spec_pr": "write",
|
|
46
|
+
"mcp__github__get_pull_request": "read",
|
|
47
|
+
"mcp__github__list_pull_requests": "read",
|
|
48
|
+
"mcp__github__list_pr_files": "read",
|
|
49
|
+
"mcp__github__list_pr_comments": "read",
|
|
50
|
+
"mcp__github__create_pull_request": "write",
|
|
51
|
+
"mcp__github__comment_on_pull_request": "write",
|
|
52
|
+
"mcp__github__create_pr_review": "write",
|
|
53
|
+
"mcp__github__reply_to_pr_comment": "write",
|
|
54
|
+
"mcp__github__resolve_pr_thread": "write",
|
|
55
|
+
"mcp__github__close_pull_request": "write",
|
|
56
|
+
"mcp__github__delete_branch": "write",
|
|
57
|
+
"mcp__github__create_release": "write",
|
|
58
|
+
"mcp__jira__search_issues": "read",
|
|
59
|
+
"mcp__jira__search_users": "read",
|
|
60
|
+
"mcp__jira__get_issue": "read",
|
|
61
|
+
"mcp__jira__list_boards": "read",
|
|
62
|
+
"mcp__jira__list_sprints": "read",
|
|
63
|
+
"mcp__jira__create_issue": "write",
|
|
64
|
+
"mcp__jira__add_comment": "write",
|
|
65
|
+
"mcp__jira__assign_issue": "write",
|
|
66
|
+
"mcp__jira__transition_issue": "write",
|
|
67
|
+
"mcp__jira__move_to_sprint": "write",
|
|
68
|
+
"mcp__jira__set_epic": "write",
|
|
69
|
+
"mcp__jira__set_story_points": "write",
|
|
70
|
+
"mcp__jira__link_pr": "write",
|
|
71
|
+
"mcp__sentry__list_issues": "read",
|
|
72
|
+
"mcp__sentry__get_issue": "read",
|
|
73
|
+
"mcp__sentry__get_latest_event": "read",
|
|
74
|
+
"mcp__aws__aws_read": "read",
|
|
75
|
+
"mcp__slack__channel_history": "read",
|
|
76
|
+
"mcp__slack__thread_replies": "read",
|
|
77
|
+
"mcp__slack__user_info": "read",
|
|
78
|
+
"mcp__slack__get_file": "read",
|
|
79
|
+
"mcp__slack__send_dm": "write",
|
|
80
|
+
"mcp__confluence__search_pages": "read",
|
|
81
|
+
"mcp__confluence__get_page": "read",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def classify_tool(tool_name: str) -> str:
|
|
86
|
+
return TOOL_CLASSIFICATION.get(tool_name, "write")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def profile_permits_writes(allowed_tools: list[str]) -> bool:
|
|
90
|
+
"""a profile tier is write-capable if its base allowlist holds a *recognized* write tool other than Bash.
|
|
91
|
+
Bash is present in every tier for investigation and is separately policed (hooks + read-only git token),
|
|
92
|
+
so its presence does not make an otherwise read-only profile eligible to gain Write/Edit/write-MCP tools.
|
|
93
|
+
Unrecognized tools are deliberately NOT treated as write here: an unclassified read tool in a read-only
|
|
94
|
+
profile (e.g. mcp__slack__user_info) must not flip the tier to write-capable and reopen the gate (§8.4)."""
|
|
95
|
+
return any(TOOL_CLASSIFICATION.get(tool_name) == "write" and tool_name != "Bash" for tool_name in allowed_tools)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def tool_grantable(tool_name: str, profile_allowed_tools: list[str]) -> bool:
|
|
99
|
+
"""whether a mid-task grant may admit this tool: it must be recognized, and a read-only profile
|
|
100
|
+
tier can only ever gain read tools — never a write tool it was deliberately denied (§8.4)."""
|
|
101
|
+
if tool_name not in TOOL_CLASSIFICATION:
|
|
102
|
+
return False
|
|
103
|
+
if classify_tool(tool_name) == "read":
|
|
104
|
+
return True
|
|
105
|
+
return profile_permits_writes(profile_allowed_tools)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def repo_grantable(target: str, approved_repos: list[str], accessible_repos: set[str] | None) -> bool:
|
|
109
|
+
"""whether an out-of-role-scope repo may be escalated for operator review (issue #39)."""
|
|
110
|
+
if "/" not in target:
|
|
111
|
+
return False
|
|
112
|
+
owner, _, name = target.partition("/")
|
|
113
|
+
if not owner or not name:
|
|
114
|
+
return False
|
|
115
|
+
allowed_orgs = {repo.split("/", 1)[0] for repo in approved_repos if "/" in repo}
|
|
116
|
+
if owner not in allowed_orgs:
|
|
117
|
+
return False
|
|
118
|
+
if accessible_repos is not None and name not in accessible_repos:
|
|
119
|
+
return False
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# git allows global options between "git" and the subcommand (issue #106); this skips a bounded, quote-aware set of them.
|
|
124
|
+
_GIT_OPT_WORD = r"(?:[^\s'\"]|'[^']*'|\"[^\"]*\")+"
|
|
125
|
+
_GIT_GLOBAL_OPT = rf"(?:-c|-C|--git-dir|--work-tree|--namespace|--config-env|--attr-source)\s+(?!-){_GIT_OPT_WORD}|-{_GIT_OPT_WORD}"
|
|
126
|
+
_GIT_PUSH = rf"\bgit\b(?:\s+(?:{_GIT_GLOBAL_OPT}))*\s+['\"]?push\b"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def bash_denial(command: str, protected_branches: list[str]) -> str | None:
|
|
130
|
+
"""returns a denial reason, or None when the command is allowed."""
|
|
131
|
+
command = command.replace("\\\n", "") # join backslash-continued lines before scanning
|
|
132
|
+
command = re.sub(r"\s(?:\d*>{1,2}|&>{1,2})\s*(?:&\d+|[^\s;&|<>]+)", " ", command) # strip redirects so a redirect target isn't scanned as guard content
|
|
133
|
+
if "169.254.169.254" in command:
|
|
134
|
+
return "access to the instance metadata service is blocked"
|
|
135
|
+
# deliberately non-exhaustive defense-in-depth; host controls remain authoritative.
|
|
136
|
+
if re.search(r"/proc/(?:self|\d+)/environ\b", command) or ("os.environ" in command and re.search(r"\bpython(?:3(?:\.\d+)?)?\b[^;&|]*\s-c\b", command)):
|
|
137
|
+
return "dumping the process environment is blocked"
|
|
138
|
+
if re.search(r"\bprintenv\b", command) or re.search(r"(^|[;&|]\s*)env\s*($|[;&|>])", command.strip()):
|
|
139
|
+
return "dumping the process environment is blocked"
|
|
140
|
+
# a wildcard refspec updates a protected branch without naming it, exactly like --all/--mirror
|
|
141
|
+
if re.search(rf"{_GIT_PUSH}[^;&|\n]*\s['\"]?(?:--(?:all|mirror)\b|\S*\*)", command):
|
|
142
|
+
return "pushing with --all, --mirror, or a wildcard refspec is blocked"
|
|
143
|
+
if re.search(rf"{_GIT_PUSH}[^;&|\n]*(?:\s['\"]?--force\b|\s['\"]?-\w*f\w*\b|\s['\"]?\+\S)", command):
|
|
144
|
+
return "force pushes are blocked"
|
|
145
|
+
# --no-verify (git accepts unambiguous abbreviations) or a hook-config override would dodge the workspace pre-push hook
|
|
146
|
+
if re.search(r"\bgit\b[^;&|\n]*\bpush\b[^;&|\n]*\s--no-veri[a-z]*\b", command):
|
|
147
|
+
return "pushing with --no-verify is blocked"
|
|
148
|
+
if (
|
|
149
|
+
re.search(r"(?:-c\s*[\"']?|--config-env=)core\.hookspath=", command, re.IGNORECASE)
|
|
150
|
+
or re.search(r"\bgit\b[^;&|\n]*\bconfig\b[^;&|\n]*core\.hookspath", command, re.IGNORECASE)
|
|
151
|
+
or re.search(r"(?:^|[;&|(\n])\s*(?:(?:env|export)\s+|\w+=\S*\s+)*GIT_CONFIG_\w+=", command)
|
|
152
|
+
or re.search(r"\benv\s+-u\s+GIT_CONFIG_\w+", command)
|
|
153
|
+
):
|
|
154
|
+
return "overriding git hook configuration is blocked"
|
|
155
|
+
for branch in protected_branches:
|
|
156
|
+
# a protected name inside a longer ref (agent/t123-main-guard-fix) is not a push to it.
|
|
157
|
+
if re.search(rf"{_GIT_PUSH}[^;&|\n]*(?<![\w.-]){re.escape(branch)}(?![\w.-])", command):
|
|
158
|
+
return f"pushing to protected branch '{branch}' is blocked"
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class TaskHooks:
|
|
163
|
+
"""per-session policy + audit, wired as a PreToolUse hook.
|
|
164
|
+
|
|
165
|
+
NOT can_use_tool: allowed_tools entries auto-approve whole tools before that callback
|
|
166
|
+
is consulted (CanUseToolShadowedWarning), which would silently skip audit and denials.
|
|
167
|
+
a PreToolUse hook fires for every call regardless of allow rules.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
def __init__(self, store: Store, task: Task, protected_branches: list[str], allowed_tools: list[str] | None = None):
|
|
171
|
+
self.store = store
|
|
172
|
+
self.task = task
|
|
173
|
+
self.protected_branches = protected_branches
|
|
174
|
+
self.allowed_tools = allowed_tools # None = no profile restriction; otherwise deny anything off-list
|
|
175
|
+
|
|
176
|
+
def _decide(self, tool_name: str, tool_input: dict) -> str | None:
|
|
177
|
+
"""audit the call and return a denial reason, or None to allow."""
|
|
178
|
+
if self.allowed_tools is not None and tool_name not in self.allowed_tools:
|
|
179
|
+
# the profile allowlist is the contract; without this, non-allowlisted tools
|
|
180
|
+
# (e.g. Task, which spawns sub-agents) fall through to the callback and slip past
|
|
181
|
+
reason = f"tool {tool_name} is not in this task's profile"
|
|
182
|
+
self.store.add_event(self.task.task_id, "security_denial", {"reason": reason, "input": _digest(tool_input)}, tool_name=tool_name, is_write=True)
|
|
183
|
+
return reason
|
|
184
|
+
if tool_name == "Bash":
|
|
185
|
+
bash_reason = bash_denial(str(tool_input.get("command", "")), self.protected_branches)
|
|
186
|
+
if bash_reason:
|
|
187
|
+
self.store.add_event(self.task.task_id, "security_denial", {"reason": bash_reason, "input": _digest(tool_input)}, tool_name=tool_name, is_write=True)
|
|
188
|
+
return bash_reason
|
|
189
|
+
self.store.add_event(self.task.task_id, "tool_call", {"input": _digest(tool_input)}, tool_name=tool_name, is_write=classify_tool(tool_name) == "write")
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
async def pre_tool_use(self, input_data: dict, tool_use_id=None, context=None) -> dict:
|
|
193
|
+
reason = self._decide(str(input_data.get("tool_name", "")), input_data.get("tool_input") or {})
|
|
194
|
+
if reason:
|
|
195
|
+
return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny", "permissionDecisionReason": reason}}
|
|
196
|
+
return {}
|
|
197
|
+
|
|
198
|
+
async def can_use_tool(self, tool_name: str, tool_input: dict, context=None) -> PermissionResultAllow | PermissionResultDeny:
|
|
199
|
+
# backstop for calls that reach the permission callback (non-allowlisted tools)
|
|
200
|
+
reason = self._decide(tool_name, tool_input)
|
|
201
|
+
if reason:
|
|
202
|
+
return PermissionResultDeny(message=reason)
|
|
203
|
+
return PermissionResultAllow()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _digest(tool_input: dict) -> str:
|
|
207
|
+
# bounded before it enters the audit trail (TOL-006); store redacts on write
|
|
208
|
+
return str(tool_input)[:300]
|
taskboy/issue_runs.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""issues triggers: run the discovery/implementation skills on demand as the system identity.
|
|
2
|
+
|
|
3
|
+
used by the dashboard's "Run discovery"/"Implement approved" buttons. recurring runs go through the
|
|
4
|
+
general task scheduler (taskboy/scheduler.py), which seeds the daily discovery/implementation jobs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
|
|
11
|
+
from taskboy.config import Config
|
|
12
|
+
from taskboy.models import BLOCKED, FAILED, Task, utcnow
|
|
13
|
+
from taskboy.orchestrator import accept_task
|
|
14
|
+
from taskboy.store import Store, TransitionRaced
|
|
15
|
+
|
|
16
|
+
# system-owned tasks reuse the "github" identity, which the default config maps to the read/standard system role
|
|
17
|
+
SYSTEM_TEAM = "github"
|
|
18
|
+
SYSTEM_USER = "github"
|
|
19
|
+
|
|
20
|
+
GITHUB_API = "https://api.github.com"
|
|
21
|
+
# not end-anchored so a /files, ?query, or #fragment suffix still parses (#87)
|
|
22
|
+
PR_URL_RE = re.compile(r"^https://github\.com/([^/\s]+/[^/\s]+)/pull/(\d+)")
|
|
23
|
+
|
|
24
|
+
# a batch this size keeps a run's PRs reviewable (also enforced by the coordinator only seeing its reserved rows)
|
|
25
|
+
IMPLEMENTATION_BATCH_SIZE = 5
|
|
26
|
+
STALLED_COORDINATOR_MAX_AGE_HOURS = 24
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def start_issue_task(store: Store, config: Config, notifier, skill: str, args: str = "", *, source: str):
|
|
30
|
+
"""enqueue one issues skill task as the system identity. returns (task, status) from accept_task."""
|
|
31
|
+
channel = str(((config.raw.get("issues") or {}).get("notify_channel")) or "")
|
|
32
|
+
key = f"issues:{skill}:{source}@{utcnow()}"
|
|
33
|
+
text = f"/{skill}"
|
|
34
|
+
if args.strip():
|
|
35
|
+
text += f" {args.strip()}"
|
|
36
|
+
return await accept_task(
|
|
37
|
+
store,
|
|
38
|
+
config,
|
|
39
|
+
notifier,
|
|
40
|
+
team_id=SYSTEM_TEAM,
|
|
41
|
+
channel_id=channel,
|
|
42
|
+
thread_ts=key,
|
|
43
|
+
message_ts=key,
|
|
44
|
+
user_id=SYSTEM_USER,
|
|
45
|
+
text=text,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def start_refine_task(store: Store, config: Config, notifier, issue_id: int, *, source: str) -> tuple[Task | None, str, str | None]:
|
|
50
|
+
"""start one refine task per issue, returning the active task id when one already exists."""
|
|
51
|
+
existing = store.active_refine_task(issue_id)
|
|
52
|
+
if existing is not None:
|
|
53
|
+
return None, "already_running", existing
|
|
54
|
+
task, status = await start_issue_task(store, config, notifier, "refineissue", str(issue_id), source=source)
|
|
55
|
+
return task, status, None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def start_implementation_run(
|
|
59
|
+
store: Store,
|
|
60
|
+
config: Config,
|
|
61
|
+
notifier,
|
|
62
|
+
*,
|
|
63
|
+
thread_key: str,
|
|
64
|
+
request_text: str = "/implementapprovedissues",
|
|
65
|
+
user_id: str = SYSTEM_USER,
|
|
66
|
+
channel_id: str | None = None,
|
|
67
|
+
model_override: str | None = None,
|
|
68
|
+
effort_override: str | None = None,
|
|
69
|
+
schedule_name: str | None = None,
|
|
70
|
+
issue_ids: list[int] | None = None,
|
|
71
|
+
actor: str | None = None,
|
|
72
|
+
) -> tuple[Task | None, str, str | None]:
|
|
73
|
+
"""with `issue_ids` omitted, reserves up to IMPLEMENTATION_BATCH_SIZE approved issues top-priority-first;
|
|
74
|
+
with `issue_ids` given, approves any of those still `proposed` (as `actor`) and reserves exactly that set.
|
|
75
|
+
Returns (task, status, active_task_id): status is "already_running", "no_approved_issues", or whatever
|
|
76
|
+
accept_task reports ("created" or a refusal like "paused"/"queue_full"/"duplicate")."""
|
|
77
|
+
existing = store.active_implementation_run()
|
|
78
|
+
if existing is not None:
|
|
79
|
+
return None, "already_running", existing
|
|
80
|
+
if store.has_pending_implementation_reservation():
|
|
81
|
+
return None, "already_running", None
|
|
82
|
+
|
|
83
|
+
marker = f"pending:{uuid.uuid4().hex[:12]}"
|
|
84
|
+
auto_approved_ids: list[int] = []
|
|
85
|
+
if issue_ids is not None:
|
|
86
|
+
for issue_id in issue_ids:
|
|
87
|
+
issue = store.get_issue(issue_id)
|
|
88
|
+
if issue is not None and issue["status"] == "proposed":
|
|
89
|
+
store.decide_issue(issue_id, "approved", actor or user_id)
|
|
90
|
+
auto_approved_ids.append(issue_id)
|
|
91
|
+
reserved = store.reserve_issues_by_id(marker, issue_ids)
|
|
92
|
+
else:
|
|
93
|
+
reserved = store.reserve_issues(marker, IMPLEMENTATION_BATCH_SIZE)
|
|
94
|
+
if not reserved:
|
|
95
|
+
return None, "no_approved_issues", None
|
|
96
|
+
# only the ones we actually reserved under this marker count — a row that missed reservation
|
|
97
|
+
# (e.g. denied by someone else in between) was never ours to roll back
|
|
98
|
+
reserved_ids = {row["id"] for row in reserved}
|
|
99
|
+
auto_approved_ids = [issue_id for issue_id in auto_approved_ids if issue_id in reserved_ids]
|
|
100
|
+
|
|
101
|
+
channel = channel_id if channel_id is not None else str(((config.raw.get("issues") or {}).get("notify_channel")) or "")
|
|
102
|
+
assigned = False
|
|
103
|
+
try:
|
|
104
|
+
task, status = await accept_task(
|
|
105
|
+
store,
|
|
106
|
+
config,
|
|
107
|
+
notifier,
|
|
108
|
+
team_id=SYSTEM_TEAM,
|
|
109
|
+
channel_id=channel,
|
|
110
|
+
thread_ts=thread_key,
|
|
111
|
+
message_ts=thread_key,
|
|
112
|
+
user_id=user_id,
|
|
113
|
+
text=request_text,
|
|
114
|
+
model_override=model_override,
|
|
115
|
+
effort_override=effort_override,
|
|
116
|
+
schedule_name=schedule_name,
|
|
117
|
+
)
|
|
118
|
+
if status != "created" or task is None:
|
|
119
|
+
return task, status, None
|
|
120
|
+
store.assign_reservation(marker, task.task_id)
|
|
121
|
+
assigned = True
|
|
122
|
+
return task, "created", None
|
|
123
|
+
finally:
|
|
124
|
+
# covers accept_task raising (incl. cancellation) and the plain refusal case above — either way, don't
|
|
125
|
+
# strand the reservation under a marker no coordinator will ever claim
|
|
126
|
+
if not assigned:
|
|
127
|
+
store.release_reserved_issues(marker)
|
|
128
|
+
# release_reserved_issues put these back to approved; anything we auto-approved from proposed
|
|
129
|
+
# goes back to proposed instead, so a failed rocket click doesn't leave it eligible for the next batch run
|
|
130
|
+
for issue_id in auto_approved_ids:
|
|
131
|
+
store.decide_issue(issue_id, "proposed", actor or user_id)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def fail_stalled_implementation_run(store: Store) -> str | None:
|
|
135
|
+
"""fail a coordinator left blocked for over a day and release its reserved issues."""
|
|
136
|
+
task_id = store.blocked_implementation_run()
|
|
137
|
+
if task_id is None:
|
|
138
|
+
return None
|
|
139
|
+
task = store.get_task(task_id)
|
|
140
|
+
cutoff = datetime.now(timezone.utc) - timedelta(hours=STALLED_COORDINATOR_MAX_AGE_HOURS)
|
|
141
|
+
if task is None or task.state != BLOCKED or datetime.fromisoformat(task.updated_at) >= cutoff:
|
|
142
|
+
return None
|
|
143
|
+
try:
|
|
144
|
+
store.transition(
|
|
145
|
+
task_id,
|
|
146
|
+
BLOCKED,
|
|
147
|
+
FAILED,
|
|
148
|
+
"recovery: implementation coordinator stalled",
|
|
149
|
+
error="implementation coordinator stalled in blocked for over 24h",
|
|
150
|
+
finished_at=utcnow(),
|
|
151
|
+
)
|
|
152
|
+
except TransitionRaced:
|
|
153
|
+
return None
|
|
154
|
+
store.add_event(task_id, "recovery", {"action": "failed_stalled_coordinator"})
|
|
155
|
+
return task_id
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def _get_pr(repo: str, number: int, token: str) -> dict:
|
|
159
|
+
"""the github http seam for sync_in_review — patched in unit tests."""
|
|
160
|
+
import aiohttp
|
|
161
|
+
|
|
162
|
+
from taskboy.adapters.github_api import GitHubStatusError
|
|
163
|
+
from taskboy.redact import redactor
|
|
164
|
+
|
|
165
|
+
headers = {"Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json"}
|
|
166
|
+
async with aiohttp.ClientSession() as session:
|
|
167
|
+
async with session.get(f"{GITHUB_API}/repos/{repo}/pulls/{number}", headers=headers) as response:
|
|
168
|
+
if response.status >= 300:
|
|
169
|
+
body = redactor.redact(await response.text())[:300]
|
|
170
|
+
raise GitHubStatusError(response.status, f"github api GET pulls/{number} failed: {response.status} — {body}")
|
|
171
|
+
return await response.json()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def sync_in_review(store: Store, broker) -> int:
|
|
175
|
+
"""resolve issues whose PR has since merged (-> done) or closed unmerged (-> failed); still-open PRs
|
|
176
|
+
are left alone. called hourly from housekeeping_loop. one row's error is recorded and skipped, not raised,
|
|
177
|
+
so it never blocks the rest of the sweep."""
|
|
178
|
+
updated = 0
|
|
179
|
+
tokens: dict[str, str] = {}
|
|
180
|
+
for row in store.list_issues(status="in_review"):
|
|
181
|
+
pr_url = row.get("pr_url")
|
|
182
|
+
if not pr_url:
|
|
183
|
+
continue
|
|
184
|
+
match = PR_URL_RE.match(pr_url)
|
|
185
|
+
if not match:
|
|
186
|
+
store.add_error("issue_runs", "BadPrUrl", f"issue #{row['id']} has an unparseable pr_url: {pr_url}", context={"issue_id": row["id"]})
|
|
187
|
+
continue
|
|
188
|
+
repo, number = match.group(1), int(match.group(2))
|
|
189
|
+
try:
|
|
190
|
+
if repo not in tokens:
|
|
191
|
+
token, _ = await broker.read_token([repo], permissions={"pull_requests": "read", "metadata": "read"})
|
|
192
|
+
tokens[repo] = token
|
|
193
|
+
pr = await _get_pr(repo, number, tokens[repo])
|
|
194
|
+
except Exception as e:
|
|
195
|
+
store.add_error("issue_runs", type(e).__name__, str(e), context={"issue_id": row["id"], "repository": repo})
|
|
196
|
+
continue
|
|
197
|
+
if pr.get("merged"):
|
|
198
|
+
store.finish_issue(row["id"], "done", pr_url)
|
|
199
|
+
updated += 1
|
|
200
|
+
elif pr.get("state") == "closed":
|
|
201
|
+
store.finish_issue(row["id"], "failed", pr_url)
|
|
202
|
+
updated += 1
|
|
203
|
+
return updated
|