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.
Files changed (95) hide show
  1. taskboy/__init__.py +0 -0
  2. taskboy/adapters/__init__.py +0 -0
  3. taskboy/adapters/_util.py +30 -0
  4. taskboy/adapters/aws_read.py +107 -0
  5. taskboy/adapters/confluence.py +88 -0
  6. taskboy/adapters/github_api.py +564 -0
  7. taskboy/adapters/issues.py +435 -0
  8. taskboy/adapters/jira.py +263 -0
  9. taskboy/adapters/sentry.py +82 -0
  10. taskboy/adapters/slack_history.py +204 -0
  11. taskboy/assets.py +27 -0
  12. taskboy/audit.py +78 -0
  13. taskboy/broker.py +266 -0
  14. taskboy/classifier.py +219 -0
  15. taskboy/cli.py +161 -0
  16. taskboy/config.py +539 -0
  17. taskboy/dashboard/__init__.py +5 -0
  18. taskboy/dashboard/api.py +963 -0
  19. taskboy/dashboard/app.py +78 -0
  20. taskboy/dashboard/auth.py +117 -0
  21. taskboy/dashboard/editors.py +140 -0
  22. taskboy/dashboard/gitops.py +60 -0
  23. taskboy/dashboard/render.py +48 -0
  24. taskboy/debug_feed.py +170 -0
  25. taskboy/deploy/env.example +17 -0
  26. taskboy/deploy/git-cred-helper.py +36 -0
  27. taskboy/deploy/install.sh +101 -0
  28. taskboy/deploy/remote-update.sh +52 -0
  29. taskboy/deploy/taskboy-restart.path +10 -0
  30. taskboy/deploy/taskboy-restart.service +8 -0
  31. taskboy/deploy/taskboy.service +22 -0
  32. taskboy/hooks.py +208 -0
  33. taskboy/issue_runs.py +203 -0
  34. taskboy/llm.py +125 -0
  35. taskboy/main.py +269 -0
  36. taskboy/memory.py +59 -0
  37. taskboy/models.py +103 -0
  38. taskboy/mrkdwn.py +19 -0
  39. taskboy/notify.py +41 -0
  40. taskboy/orchestrator.py +401 -0
  41. taskboy/personality.py +17 -0
  42. taskboy/prompts.py +288 -0
  43. taskboy/quick.py +234 -0
  44. taskboy/redact.py +49 -0
  45. taskboy/repocache.py +117 -0
  46. taskboy/review_requests.py +388 -0
  47. taskboy/router.py +146 -0
  48. taskboy/runner.py +581 -0
  49. taskboy/scheduler.py +301 -0
  50. taskboy/secrets.py +83 -0
  51. taskboy/settings.py +19 -0
  52. taskboy/setup_checks.py +184 -0
  53. taskboy/setup_wizard.py +699 -0
  54. taskboy/skills.py +95 -0
  55. taskboy/slack.py +666 -0
  56. taskboy/slack_users.py +32 -0
  57. taskboy/started_messages.py +33 -0
  58. taskboy/store.py +1393 -0
  59. taskboy/task_actions.py +86 -0
  60. taskboy/templates/config.example.yaml +149 -0
  61. taskboy/templates/conventions.md +60 -0
  62. taskboy/templates/help.example.md +29 -0
  63. taskboy/templates/personality_agent.example.md +5 -0
  64. taskboy/templates/personality_reviewer.example.md +5 -0
  65. taskboy/templates/services/aws.yaml +7 -0
  66. taskboy/templates/services/confluence.yaml +4 -0
  67. taskboy/templates/services/github.yaml +14 -0
  68. taskboy/templates/services/jira.yaml +6 -0
  69. taskboy/templates/services/sentry.yaml +4 -0
  70. taskboy/templates/services/slack.yaml +9 -0
  71. taskboy/templates/skills/README.md +33 -0
  72. taskboy/templates/skills/discoverissues/SKILL.md +46 -0
  73. taskboy/templates/skills/implementapprovedissues/SKILL.md +41 -0
  74. taskboy/templates/skills/jira2pr/SKILL.md +66 -0
  75. taskboy/templates/skills/monitor/SKILL.md +32 -0
  76. taskboy/templates/skills/monitornew/SKILL.md +27 -0
  77. taskboy/templates/skills/refineissue/SKILL.md +35 -0
  78. taskboy/templates/skills/release/SKILL.md +36 -0
  79. taskboy/templates/skills/review/SKILL.md +89 -0
  80. taskboy/templates/skills/reviewandmonitor/SKILL.md +22 -0
  81. taskboy/templates/skills/reviews/SKILL.md +26 -0
  82. taskboy/templates/skills/slack2jira/SKILL.md +47 -0
  83. taskboy/templates/skills/slack2pr/SKILL.md +64 -0
  84. taskboy/templates/skills/spec2pr/SKILL.md +35 -0
  85. taskboy/templates/slack_app_manifest.yaml +48 -0
  86. taskboy/templates/task_started_messages.yaml +14 -0
  87. taskboy/ui_dist/assets/index-DmYxR9Qy.css +1 -0
  88. taskboy/ui_dist/assets/index-LDDO4iT2.js +15 -0
  89. taskboy/ui_dist/index.html +15 -0
  90. taskboy/workspace.py +86 -0
  91. taskboy-0.1.1.dist-info/METADATA +120 -0
  92. taskboy-0.1.1.dist-info/RECORD +95 -0
  93. taskboy-0.1.1.dist-info/WHEEL +5 -0
  94. taskboy-0.1.1.dist-info/entry_points.txt +2 -0
  95. 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